BigQuery Analytics Dashboard
Build a BI-style Streamlit dashboard powered by Google BigQuery.
Introduction
30 Second Summary
Every data team needs a way to turn raw numbers into decisions. Dashboards are the bridge between a warehouse full of data and the humans who need to act on it.
In this project, you will build a professional analytics dashboard that connects directly to Google's cloud data warehouse and visualizes NYC bike trip data with interactive filters and charts.
What You'll Build
A Streamlit web application connected to Google BigQuery that displays NYC Citi Bike trip data through KPI metrics, interactive sidebar filters, and Plotly visualizations in a tabbed layout.
By the end of this project, you'll have:
- A Streamlit dashboard pulling live data from Google BigQuery using service account authentication and a secure secrets file.
- Multiple parameterized SQL queries with caching that respond dynamically to interactive sidebar filters.
- A polished, BI-style layout with KPI metric cards, tabbed Plotly charts, and custom CSS styling.
- Secret Mission: A geographic station map visualization showing bike stations sized by trip popularity.
Are there any prerequisites?
You should be comfortable with Python basics including functions, imports, pip, and virtual environments. Basic SQL knowledge (SELECT, WHERE, GROUP BY) is also helpful.
No prior experience with Streamlit, BigQuery, or Plotly is required.
Set Up Your Environment and Google Cloud
To build a dashboard that pulls live data from a cloud data warehouse, you need a local Python environment and a connection to Google BigQuery.
In this step, you will install your dependencies, create a Google Cloud project with the BigQuery API enabled, and wire up a secure connection using a service account.
In this step, get ready to:
- Install Python 3.11+ and project dependencies in a virtual environment.
- Create a Google Cloud project with a BigQuery service account.
- Set up your project files and verify the BigQuery connection.
Install Python and create your project environment
This project requires Python 3.11 or higher. Let's confirm you have it installed.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Check your Python version by running:
python3 --version
✔️ I see version 3.11 or higher
Your Python version is ready. Move on to creating your project folder below.
ⓧ I see a version lower than 3.11
You have Python installed, but it is an older version. macOS often ships with an older Python by default.
- Download and install Python 3.11+ from the official site: https://www.python.org/downloads/.
- After installing, close and reopen your terminal, then run python3.11 --version to confirm the new version is available.
- If python3 still shows the old version, use python3.11 explicitly for the rest of this project.
ⓧ Command not found
Python is not installed on your system yet.
- Download and install Python 3.11+ from: https://www.python.org/downloads/.
- During installation on Windows, check the box that says Add Python to PATH.
- Close and reopen your terminal after installation, then run python3 --version again.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir bigquery-dashboard
cd bigquery-dashboard
- Create and activate a virtual environment by running:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
You should see (venv) at the start of your terminal prompt. This confirms the virtual environment is active.
- Install all project dependencies by running:
pip install streamlit==1.58.0 google-cloud-bigquery==3.42.1 plotly==6.8.0 pandas==2.3.3 db-dtypes==1.4.2
What are these packages?
- streamlit is the Python framework for building interactive dashboards.
- google-cloud-bigquery is the official client library for querying BigQuery.
- plotly creates interactive charts.
- pandas handles data as DataFrames.
- db-dtypes converts BigQuery-specific column types (DATE, TIME, GEOGRAPHY) into pandas columns.
- Save your dependencies to a requirements.txt file by running:
pip freeze | grep -E "streamlit|google-cloud-bigquery|plotly|pandas|db-dtypes" > requirements.txt
- Open requirements.txt and replace its contents with exactly this:
streamlit==1.58.0
google-cloud-bigquery==3.42.1
plotly==6.8.0
pandas==2.3.3
db-dtypes==1.4.2
Why pin exact versions?
Pinning exact versions ensures anyone who clones your project gets the same dependencies. This prevents unexpected breakage when libraries release updates.
- Create a .gitignore file in your bigquery-dashboard folder with the following contents:
.streamlit/secrets.toml
__pycache__/
*.pyc
venv/
.env
Why add secrets.toml to .gitignore?
Your .streamlit/secrets.toml file will contain your Google Cloud service account private key. Adding it to .gitignore ensures you never accidentally commit credentials to version control.
Seeing errors during pip install?
- Make sure your virtual environment is active (you see (venv) in your prompt).
- If you see permission errors, do not use sudo. Deactivate and recreate the venv instead.
Having trouble with pip install?
Create a Google Cloud project and service account
Your dashboard needs to authenticate with Google Cloud to query BigQuery public datasets. You will create a service account and download its JSON key file.
What is BigQuery sandbox?
BigQuery sandbox lets you query public datasets without a credit card or billing account. You get 1 TiB of free query processing per month. This project stays comfortably within that limit.
- Open the Google Cloud Console at https://console.cloud.google.com.
- Sign in with your Google account. If this is your first time, you may be prompted to agree to the Terms of Service.
- Click the project selector dropdown at the top of the page.
- Click New Project.
- Enter a project name (for example, citibike-dashboard) and click Create.
- Once created, make sure your new project is selected in the project selector dropdown.
Now enable the BigQuery API for your project.
- In the left navigation menu, click APIs & Services, then click Library.
- Search for BigQuery API in the search box.
- Click the BigQuery API result.
- Click Enable. If it already shows "API enabled", you are all set.
New projects auto-enable BigQuery
For most new Google Cloud projects, the BigQuery API is automatically enabled. If you see it is already active, skip the Enable step and continue.
Next, create a service account that your app will use to authenticate.
- In the left navigation menu, click IAM & Admin, then click Service Accounts.
- Click Create Service Account at the top of the page.
- In the Service account name field, enter bigquery-dashboard.
- Click Create and continue.
- In the Select a role dropdown, search for and select BigQuery Data Viewer.
- Click Continue.
- Click Done.
What is BigQuery Data Viewer?
The BigQuery Data Viewer role grants read-only access to query data in BigQuery datasets. This follows the principle of least privilege. Your dashboard only reads data, so it does not need write permissions.
Now download the JSON key file for your service account.
- In the Service Accounts list, click the email address of the bigquery-dashboard service account you just created.
- Click the Keys tab.
- Click Add key, then click Create new key.
- Select JSON as the key type and click Create.
- A JSON file downloads to your computer automatically. Keep this file safe. You cannot download it again.
Keep your key file secure
This JSON file contains your private key. Never commit it to Git, share it publicly, or include it in screenshots. You will copy its contents into secrets.toml in the next substep.
Set up secrets.toml and verify the BigQuery connection
Streamlit uses a .streamlit/secrets.toml file to securely pass credentials to your app without hardcoding them in source code.
- Create the .streamlit folder inside your bigquery-dashboard directory by running:
mkdir .streamlit
- Create a file called secrets.toml inside the .streamlit folder.
- Open the JSON key file you downloaded earlier in a text editor.
- Copy the values from your JSON key into secrets.toml using the following format:
[gcp_service_account]
type = "service_account"
project_id = "your-project-id-here"
private_key_id = "your-private-key-id-here"
private_key = "your-private-key-here"
client_email = "your-service-account-email-here"
client_id = "your-client-id-here"
auth_uri = "https://accounts.google.com/o/oauth2/auth"
token_uri = "https://oauth2.googleapis.com/token"
auth_provider_x509_cert_url = "https://www.googleapis.com/oauth2/v1/certs"
client_x509_cert_url = "your-cert-url-here"
How do I fill this in?
Open the downloaded JSON key file. Each field in the JSON maps directly to a line in secrets.toml. For example, the project_id value in your JSON becomes the project_id value in the TOML file.
The private_key field is a long string that starts with -----BEGIN PRIVATE KEY-----. Paste the entire value including the begin/end markers, wrapped in quotes.
Now verify that your connection to BigQuery works. Run a quick test query.
- Create a test file called test_connection.py with this code:
import streamlit as st
from google.oauth2 import service_account
from google.cloud import bigquery
# Load credentials from secrets.toml
credentials = service_account.Credentials.from_service_account_info(
st.secrets["gcp_service_account"]
)
client = bigquery.Client(credentials=credentials)
# Run a test query against the Citi Bike public dataset
query = """
SELECT COUNT(*) as total_trips
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = 2018
"""
result = client.query(query).to_dataframe()
print(f"Total 2018 trips: {result['total_trips'].iloc[0]:,}")
print("Connection successful!")
- Run the test by executing:
streamlit run test_connection.py
Why use streamlit run for a test?
The st.secrets object is only available when Streamlit is running. Using streamlit run loads your .streamlit/secrets.toml automatically.
You should see a trip count printed in your terminal output (the number will be in the millions). You will also see a Streamlit app open in your browser. You can close it.
- Press Ctrl+C in your terminal to stop the Streamlit server.
- Delete the test file since it was only for verification:
rm test_connection.py
- Confirm your project structure by running:
ls -la
You should see .gitignore, .streamlit/, requirements.txt, and venv/ listed.
Connection failing or permission denied?
- Double-check that the values in secrets.toml exactly match your downloaded JSON key (especially private_key with no missing characters).
- Make sure you assigned the BigQuery Data Viewer role to your service account during creation.
- Verify the BigQuery API is enabled in your Google Cloud project under APIs & Services.
My BigQuery test query is failing. How do I fix it?
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here are the three files you should have in your bigquery-dashboard folder at this point:
streamlit==1.58.0
google-cloud-bigquery==3.42.1
plotly==6.8.0
pandas==2.3.3
db-dtypes==1.4.2
.streamlit/secrets.toml
__pycache__/
*.pyc
venv/
.env
[gcp_service_account]
type = "service_account"
project_id = "your-project-id-here"
private_key_id = "your-private-key-id-here"
private_key = "your-private-key-here"
client_email = "your-service-account-email-here"
client_id = "your-client-id-here"
auth_uri = "https://accounts.google.com/o/oauth2/auth"
token_uri = "https://oauth2.googleapis.com/token"
auth_provider_x509_cert_url = "https://www.googleapis.com/oauth2/v1/certs"
client_x509_cert_url = "your-cert-url-here"
Your environment is ready and authenticated. Next up, you will build the BigQuery data layer with parameterized queries and caching.
Build the BigQuery Data Layer
Your environment is ready and your credentials are securely stored. Now you need a way for Python to talk to BigQuery and pull live trip data.
In this step, you will create the app's data layer. This means writing functions that send SQL queries to BigQuery and return DataFrames that the dashboard can visualize.
In this step, get ready to:
- Initialize a BigQuery client with cached credentials.
- Write parameterized SQL query functions for trip trends and station rankings.
- Write query functions for demographics and KPI metrics.
Create the app file and connect to BigQuery
Before you can query any data, your app needs to authenticate with Google Cloud. You will use the service account credentials you saved in secrets.toml to create an authenticated BigQuery client.
- Create a new file called streamlit_app.py in the root of your project folder (the same directory that contains requirements.txt).
- Add the following imports and BigQuery client initialization code:
import streamlit as st
from google.oauth2 import service_account
from google.cloud import bigquery
import plotly.express as px
import pandas as pd
# Create a cached BigQuery client using service account credentials
@st.cache_resource
def get_bigquery_client():
credentials = service_account.Credentials.from_service_account_info(
st.secrets["gcp_service_account"]
)
return bigquery.Client(credentials=credentials)
# Initialize the client once and reuse across the app
client = get_bigquery_client()
What does this code do?
The imports bring in Streamlit for the app framework, service_account for authentication, bigquery for querying, Plotly for charts, and Pandas for DataFrames.
The @st.cache_resource decorator tells Streamlit to create the BigQuery client only once, then reuse it on every page reload. Without caching, the app would re-authenticate on every interaction.
The st.secrets["gcp_service_account"] call reads your credentials from .streamlit/secrets.toml so they never appear in your code.
- Save streamlit_app.py.
- Test the connection by running the app:
streamlit run streamlit_app.py
- Your browser opens to a blank page. Check the terminal for errors. If you see no errors, the connection to BigQuery is working.
- Press Ctrl+C in the terminal to stop the app for now.
Seeing a credentials error?
Make sure your .streamlit/secrets.toml file is in the same directory where you ran the command. The [gcp_service_account] section must contain the exact JSON key fields from your downloaded service account file.
Double-check that private_key includes the full key string with \n line breaks intact.
help me fix my BigQuery credentials error in Streamlit
Write the trip trends and station ranking queries
The dashboard needs data broken down by day and by station. Each query function accepts filter parameters so the sidebar controls you build later can dynamically change what data the user sees.
- In streamlit_app.py, add the daily trips query function below the client = get_bigquery_client() line:
# Query daily trip counts, filtered by year and optional user type
@st.cache_data(ttl=600)
def query_daily_trips(year, user_type):
user_filter = ""
if user_type != "All":
user_filter = f"AND usertype = '{user_type}'"
query = f"""
SELECT
DATE(starttime) AS trip_date,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
{user_filter}
GROUP BY trip_date
ORDER BY trip_date
"""
return client.query(query).to_dataframe()
What does this code do?
The @st.cache_data(ttl=600) decorator caches query results for 10 minutes. This means repeated page interactions do not re-run the BigQuery query, saving you quota and speeding up the app.
The user_filter variable conditionally adds a WHERE clause for user type. When the user selects "All" in the sidebar, no filter is applied. The query groups trips by date to produce a time-series of daily ride counts.
- Below query_daily_trips, add the station ranking query function:
# Query top 15 stations by trip count
@st.cache_data(ttl=600)
def query_station_ranking(year, user_type):
user_filter = ""
if user_type != "All":
user_filter = f"AND usertype = '{user_type}'"
query = f"""
SELECT
start_station_name,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
AND start_station_name IS NOT NULL
{user_filter}
GROUP BY start_station_name
ORDER BY trip_count DESC
LIMIT 15
"""
return client.query(query).to_dataframe()
What does this code do?
This function ranks stations by how many trips started there. The LIMIT 15 keeps the bar chart readable by showing only the busiest stations.
The start_station_name IS NOT NULL filter removes incomplete records where the station name is missing.
- Save streamlit_app.py.
- You will not see a visible change yet. These functions are not called until the dashboard UI is built in the next step.
Seeing a syntax error on save?
Make sure the triple-quoted SQL strings use three double quotes (""") and that the f-string braces {year} and {user_filter} are not inside single braces meant for SQL. Python f-strings use single braces for interpolation.
help me fix a syntax error in my BigQuery query function
Write the demographics and KPI queries
The dashboard also needs aggregate stats for KPI cards and a breakdown by user type and gender. These final three functions complete the data layer.
- Below query_station_ranking, add the demographics query:
# Query trip distribution by user type and gender
@st.cache_data(ttl=600)
def query_demographics(year):
query = f"""
SELECT
usertype,
gender,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
AND usertype IS NOT NULL
AND gender != 'unknown'
GROUP BY usertype, gender
ORDER BY trip_count DESC
"""
return client.query(query).to_dataframe()
What does this code do?
This query groups trips by both usertype (Subscriber vs Customer) and gender. The filters exclude null user types and unknown genders so the chart only shows meaningful categories.
- Next, add the two KPI metric queries:
# Query aggregate metrics for the KPI cards
@st.cache_data(ttl=600)
def query_kpi_metrics(year):
query = f"""
SELECT
COUNT(*) AS total_trips,
AVG(tripduration) / 60.0 AS avg_duration_min,
COUNT(DISTINCT start_station_name) AS unique_stations
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
"""
return client.query(query).to_dataframe()
# Query previous year's trip count for year-over-year comparison
@st.cache_data(ttl=600)
def query_kpi_previous_year(year):
prev_year = year - 1
query = f"""
SELECT
COUNT(*) AS total_trips
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {prev_year}
"""
return client.query(query).to_dataframe()
What do these queries do?
The query_kpi_metrics function returns three numbers in one query: total trips, average trip duration in minutes, and the count of unique stations. This powers the KPI cards at the top of the dashboard.
The query_kpi_previous_year function fetches the prior year's total trips so you can calculate a year-over-year growth percentage for the delta indicator.
- Save streamlit_app.py.
- Run the app again to confirm there are no import or syntax errors:
streamlit run streamlit_app.py
- You should see a blank page with no errors in the terminal. This confirms that all five query functions loaded successfully and the BigQuery client connected.
- Press Ctrl+C to stop the app.
Seeing an import error for db-dtypes?
The db-dtypes package is required by google-cloud-bigquery to convert BigQuery-specific types (like DATE) to Pandas columns. Make sure your virtual environment is activated and all packages from requirements.txt are installed.
help me fix an import error when running my Streamlit BigQuery app
✔️ Awesome, I've got everything!
Great. Double check you have saved streamlit_app.py with all five query functions, then move on.
ⓧ I'd like to double check the full code
Here is the complete streamlit_app.py at the end of this step:
import streamlit as st
from google.oauth2 import service_account
from google.cloud import bigquery
import plotly.express as px
import pandas as pd
@st.cache_resource
def get_bigquery_client():
credentials = service_account.Credentials.from_service_account_info(
st.secrets["gcp_service_account"]
)
return bigquery.Client(credentials=credentials)
client = get_bigquery_client()
@st.cache_data(ttl=600)
def query_daily_trips(year, user_type):
user_filter = ""
if user_type != "All":
user_filter = f"AND usertype = '{user_type}'"
query = f"""
SELECT
DATE(starttime) AS trip_date,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
{user_filter}
GROUP BY trip_date
ORDER BY trip_date
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_station_ranking(year, user_type):
user_filter = ""
if user_type != "All":
user_filter = f"AND usertype = '{user_type}'"
query = f"""
SELECT
start_station_name,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
AND start_station_name IS NOT NULL
{user_filter}
GROUP BY start_station_name
ORDER BY trip_count DESC
LIMIT 15
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_demographics(year):
query = f"""
SELECT
usertype,
gender,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
AND usertype IS NOT NULL
AND gender != 'unknown'
GROUP BY usertype, gender
ORDER BY trip_count DESC
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_kpi_metrics(year):
query = f"""
SELECT
COUNT(*) AS total_trips,
AVG(tripduration) / 60.0 AS avg_duration_min,
COUNT(DISTINCT start_station_name) AS unique_stations
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_kpi_previous_year(year):
prev_year = year - 1
query = f"""
SELECT
COUNT(*) AS total_trips
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {prev_year}
"""
return client.query(query).to_dataframe()
Your data layer is complete. Next up, you will build the dashboard's visual layout with KPI metric cards, sidebar filters, and custom styling that brings these queries to life.
Build the Dashboard Layout with KPI Metrics
Your data layer is complete. You have five query functions ready to pull Citi Bike trip data from BigQuery.
Now you need a way for users to interact with that data. In this step, you will build the visual shell of your dashboard: page configuration, a filter sidebar, and a row of KPI metric cards that summarize the dataset at a glance.
In this step, get ready to:
- Configure the page layout and inject custom CSS for a polished look.
- Create sidebar filter controls that drive your parameterized queries.
- Build a KPI metrics row displaying trip volume, duration, station count, and year-over-year growth.
Configure the page and inject custom CSS
Streamlit apps default to a narrow, centered column. A data dashboard needs the full viewport width to fit metric cards, charts, and filters side by side.
- In streamlit_app.py, find the line that says @st.cache_resource (the decorator above your get_bigquery_client function).
- Add the following page configuration code directly above that @st.cache_resource line, with a blank line separating them:
# Page config
st.set_page_config(
page_title="NYC Citi Bike Analytics",
page_icon="\ud83d\udeb2",
layout="wide",
initial_sidebar_state="expanded",
)
What does this code do?
- page_title and page_icon set what appears in the browser tab.
- layout="wide" stretches the app across the full browser width instead of a narrow centered column.
- initial_sidebar_state="expanded" ensures the filter sidebar is visible when the page first loads.
Why must this be the first Streamlit command?
Streamlit requires st.set_page_config to be the very first Streamlit command in your script. Python import statements are fine above it, but no other st.* call can come before it.
- Save streamlit_app.py.
Seeing a set_page_config error?
If Streamlit complains that set_page_config must be the first command, make sure no other st.* calls appear above it. The @st.cache_resource decorator counts as a Streamlit call, so st.set_page_config must sit above it.
help me fix a set_page_config ordering error in my Streamlit app
Next, you will inject custom CSS to style the metric cards that you will build at the end of this step.
- Directly below the st.set_page_config(...) block you just added, paste this CSS injection:
# Custom CSS
st.markdown(
"""
<style>
[data-testid="stMetric"] {
background-color: #f0f2f6;
border: 1px solid #e0e3e8;
border-radius: 8px;
padding: 12px 16px;
}
[data-testid="stMetricLabel"] {
font-size: 0.85rem;
color: #555;
}
[data-testid="stMetricValue"] {
font-size: 1.6rem;
font-weight: 700;
}
.block-container {
padding-top: 1.5rem;
}
</style>
""",
unsafe_allow_html=True,
)
What does this CSS do?
Streamlit exposes stable data-testid attributes on its built-in components. You are targeting the metric card container, label, and value to give each card a subtle background, rounded corners, and larger text.
The .block-container rule reduces the default top padding so your dashboard feels tighter and more professional.
- Save streamlit_app.py.
- You will not see visible changes yet because no content uses these styles. The next chunks add the elements that the CSS targets.
Create the sidebar filters
Dashboards need controls that let users slice the data. You will add a sidebar with a year picker and a user-type filter that feed into the parameterized query functions you built in the previous step.
- Scroll to the bottom of streamlit_app.py (after the query_kpi_previous_year function) and paste the following sidebar block:
# Sidebar
with st.sidebar:
st.title("Filters")
st.markdown("---")
selected_year = st.selectbox("Year", [2018, 2017, 2016, 2015, 2014], index=0)
selected_user_type = st.selectbox("User Type", ["All", "Subscriber", "Customer"])
st.markdown("---")
st.caption("Data: NYC Citi Bike (BigQuery Public Dataset)")
What does this code do?
- with st.sidebar: places everything inside it into the collapsible left panel.
- st.selectbox("Year", ...) creates a dropdown defaulting to 2018. The returned value is stored in selected_year so you can pass it to your query functions.
- st.selectbox("User Type", ...) lets users filter by Subscriber, Customer, or All.
- st.caption adds a small data-source attribution at the bottom of the sidebar.
- Save streamlit_app.py and run the app by executing this command in your terminal:
streamlit run streamlit_app.py
- Your browser opens a new tab. You should see an expanded sidebar on the left with a Year dropdown set to 2018 and a User Type dropdown set to All.
Sidebar not showing?
Make sure initial_sidebar_state="expanded" is set in your st.set_page_config call. If your browser window is narrow, Streamlit may auto-collapse the sidebar. Widen the window or click the arrow icon in the top-left corner to expand it.
help me debug why my Streamlit sidebar is not visible
Build the KPI metrics row
The top of a BI dashboard typically shows high-level KPIs that summarize the dataset. You will display total trips, average duration, unique stations, and year-over-year growth in a four-column layout.
- Below the sidebar block you just added, paste the dashboard header:
# Header
st.title("NYC Citi Bike Analytics Dashboard")
st.markdown(
f"Exploring **{selected_year}** trip data"
+ (f" for **{selected_user_type}** users" if selected_user_type != "All" else "")
)
What does this code do?
st.title renders the main page heading. The st.markdown line builds a dynamic subtitle that reflects the current filter selections from the sidebar. If the user picks a specific user type, it appears in the subtitle automatically.
- Save and check your browser. You should see the title "NYC Citi Bike Analytics Dashboard" with a subtitle reading "Exploring 2018 trip data" in the main content area.
Now add the KPI calculation and display code directly below the header you just pasted.
- Paste this KPI metrics block below the st.markdown(...) subtitle line:
# KPI Metrics
kpi_df = query_kpi_metrics(selected_year)
prev_df = query_kpi_previous_year(selected_year)
total_trips = int(kpi_df["total_trips"].iloc[0])
avg_duration = float(kpi_df["avg_duration_min"].iloc[0])
unique_stations = int(kpi_df["unique_stations"].iloc[0])
prev_trips = int(prev_df["total_trips"].iloc[0]) if not prev_df.empty else 0
yoy_delta = (
round((total_trips - prev_trips) / prev_trips * 100, 1) if prev_trips > 0 else 0
)
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric("Total Trips", f"{total_trips:,.0f}", delta=f"{yoy_delta}% YoY")
with col2:
st.metric("Avg Duration", f"{avg_duration:.1f} min")
with col3:
st.metric("Unique Stations", f"{unique_stations:,}")
with col4:
st.metric("Data Year", str(selected_year))
st.markdown("---")
What does this code do?
- query_kpi_metrics(selected_year) and query_kpi_previous_year(selected_year) call the cached query functions you built earlier. The results come back as DataFrames.
- The calculation section extracts scalar values from each DataFrame and computes year-over-year growth as a percentage.
- st.columns(4) creates four equal-width columns. Each column gets an st.metric widget that shows a label, a value, and an optional delta indicator.
- The delta parameter on the first metric shows a green or red arrow indicating growth or decline compared to the previous year.
- Save streamlit_app.py and refresh your browser.
- You should see four styled metric cards in a row below the title. The first card shows Total Trips with a green or red YoY percentage, the second shows Avg Duration in minutes, the third shows Unique Stations, and the fourth displays the selected year.
Seeing a KeyError or empty metrics?
If you see an error referencing total_trips or avg_duration_min, your query_kpi_metrics function may have a typo in the SQL column alias. Double check that the SELECT aliases in your query match the column names used in this KPI block (total_trips, avg_duration_min, unique_stations).
If you see a connection error, confirm your .streamlit/secrets.toml has valid service account credentials and that the BigQuery API is enabled in your Google Cloud project.
help me debug KPI metric errors in my Streamlit BigQuery dashboard
✔️ Awesome, I've got everything!
Great. Double check you have saved streamlit_app.py and your browser shows the KPI row.
ⓧ I'd like to double check the full code
Here is the complete streamlit_app.py at the end of this step:
import streamlit as st
from google.oauth2 import service_account
from google.cloud import bigquery
import plotly.express as px
import pandas as pd
# Page config
st.set_page_config(
page_title="NYC Citi Bike Analytics",
page_icon="\ud83d\udeb2",
layout="wide",
initial_sidebar_state="expanded",
)
# Custom CSS
st.markdown(
"""
<style>
[data-testid="stMetric"] {
background-color: #f0f2f6;
border: 1px solid #e0e3e8;
border-radius: 8px;
padding: 12px 16px;
}
[data-testid="stMetricLabel"] {
font-size: 0.85rem;
color: #555;
}
[data-testid="stMetricValue"] {
font-size: 1.6rem;
font-weight: 700;
}
.block-container {
padding-top: 1.5rem;
}
</style>
""",
unsafe_allow_html=True,
)
@st.cache_resource
def get_bigquery_client():
credentials = service_account.Credentials.from_service_account_info(
st.secrets["gcp_service_account"]
)
return bigquery.Client(credentials=credentials)
client = get_bigquery_client()
@st.cache_data(ttl=600)
def query_daily_trips(year, user_type):
user_filter = ""
if user_type != "All":
user_filter = f"AND usertype = '{user_type}'"
query = f"""
SELECT
DATE(starttime) AS trip_date,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
{user_filter}
GROUP BY trip_date
ORDER BY trip_date
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_station_ranking(year, user_type):
user_filter = ""
if user_type != "All":
user_filter = f"AND usertype = '{user_type}'"
query = f"""
SELECT
start_station_name,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
AND start_station_name IS NOT NULL
{user_filter}
GROUP BY start_station_name
ORDER BY trip_count DESC
LIMIT 15
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_demographics(year):
query = f"""
SELECT
usertype,
gender,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
AND usertype IS NOT NULL
AND gender != 'unknown'
GROUP BY usertype, gender
ORDER BY trip_count DESC
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_kpi_metrics(year):
query = f"""
SELECT
COUNT(*) AS total_trips,
AVG(tripduration) / 60.0 AS avg_duration_min,
COUNT(DISTINCT start_station_name) AS unique_stations
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_kpi_previous_year(year):
prev_year = year - 1
query = f"""
SELECT
COUNT(*) AS total_trips
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {prev_year}
"""
return client.query(query).to_dataframe()
# Sidebar
with st.sidebar:
st.title("Filters")
st.markdown("---")
selected_year = st.selectbox("Year", [2018, 2017, 2016, 2015, 2014], index=0)
selected_user_type = st.selectbox("User Type", ["All", "Subscriber", "Customer"])
st.markdown("---")
st.caption("Data: NYC Citi Bike (BigQuery Public Dataset)")
# Header
st.title("NYC Citi Bike Analytics Dashboard")
st.markdown(
f"Exploring **{selected_year}** trip data"
+ (f" for **{selected_user_type}** users" if selected_user_type != "All" else "")
)
# KPI Metrics
kpi_df = query_kpi_metrics(selected_year)
prev_df = query_kpi_previous_year(selected_year)
total_trips = int(kpi_df["total_trips"].iloc[0])
avg_duration = float(kpi_df["avg_duration_min"].iloc[0])
unique_stations = int(kpi_df["unique_stations"].iloc[0])
prev_trips = int(prev_df["total_trips"].iloc[0]) if not prev_df.empty else 0
yoy_delta = (
round((total_trips - prev_trips) / prev_trips * 100, 1) if prev_trips > 0 else 0
)
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric("Total Trips", f"{total_trips:,.0f}", delta=f"{yoy_delta}% YoY")
with col2:
st.metric("Avg Duration", f"{avg_duration:.1f} min")
with col3:
st.metric("Unique Stations", f"{unique_stations:,}")
with col4:
st.metric("Data Year", str(selected_year))
st.markdown("---")
Your dashboard has a professional layout with live KPI metrics pulling directly from BigQuery. Next up, you will add tabbed chart sections with Plotly visualizations to explore the data in depth.
Add Tabbed Chart Sections with Plotly
Your dashboard now has a header, sidebar filters, and a KPI metrics row pulling live data from BigQuery. The numbers tell a story at a glance, but data teams need to explore trends over time and compare segments visually.
In this step, you will build the main chart section of the dashboard using Plotly and Streamlit's tabbed layout. Each tab focuses on a different analytical angle of the Citi Bike data.
In this step, get ready to:
- Create a three-tab layout using Streamlit tabs.
- Build a time-series line chart showing daily trip volume.
- Build a horizontal bar chart and a grouped bar chart for station and demographic analysis.
Create the tabbed layout
Streamlit's st.tabs widget lets you organize content into clickable tabs. Each tab acts as a container that only shows its contents when selected.
You will create three tabs, one for each analytical view. The tabs need content inside them to render, so you will add the first chart in the next substep.
Build the Trip Trends line chart
- In streamlit_app.py, find the st.markdown("---") line at the very bottom of the file (below the KPI metrics).
- Add the following code below that line to create the tabs and build the Trip Trends chart:
# Tabbed charts
tab1, tab2, tab3 = st.tabs(["Trip Trends", "Station Analysis", "Demographics"])
with tab1:
daily_df = query_daily_trips(selected_year, selected_user_type)
if not daily_df.empty:
fig = px.line(
daily_df,
x="trip_date",
y="trip_count",
title=f"Daily Trip Volume ({selected_year})",
labels={"trip_date": "Date", "trip_count": "Number of Trips"},
)
fig.update_layout(hovermode="x unified", height=450)
fig.update_traces(line_color="#1f77b4")
st.plotly_chart(fig, use_container_width=True)
else:
st.warning("No data available for the selected filters.")
What does this code do?
st.tabs(["Trip Trends", "Station Analysis", "Demographics"]) creates three clickable tabs and returns a reference to each one. You use with tab1: to place content inside that specific tab.
Inside the Trip Trends tab, query_daily_trips fetches daily trip counts from BigQuery. The px.line call creates a time-series line chart. The hovermode="x unified" setting shows a single tooltip for all data points at the same x-position.
- Save streamlit_app.py.
- Switch back to your browser running the Streamlit app. You should see three tab headers ("Trip Trends", "Station Analysis", "Demographics") below the KPI row. The Trip Trends tab displays a blue line chart showing daily trip counts across the selected year.
Don't see the tabs or chart?
- Make sure the new code is placed after the last st.markdown("---") line, not inside the KPI columns block.
- Check your indentation. The code inside with tab1: must be indented one level.
- If you see a warning message instead of a chart, your BigQuery connection is working but the query returned no rows for the selected year and user type. Try changing the sidebar filters.
Help me debug why my Streamlit tabs or Plotly chart aren't rendering.
Build the Station Analysis and Demographics charts
The remaining two tabs each serve a different analytical purpose. Station Analysis shows which bike stations are busiest. Demographics breaks down trips by user type and gender.
- In streamlit_app.py, add the following code directly below the with tab1: block (after the st.warning line in the else branch) to build the Station Analysis tab:
with tab2:
station_df = query_station_ranking(selected_year, selected_user_type)
if not station_df.empty:
fig = px.bar(
station_df,
x="trip_count",
y="start_station_name",
orientation="h",
title=f"Top 15 Stations by Trip Count ({selected_year})",
labels={
"trip_count": "Number of Trips",
"start_station_name": "Station",
},
)
fig.update_layout(yaxis={"categoryorder": "total ascending"}, height=500)
fig.update_traces(marker_color="#2ca02c")
st.plotly_chart(fig, use_container_width=True)
else:
st.warning("No station data available.")
What does this code do?
px.bar with orientation="h" creates a horizontal bar chart. This orientation works well for station names because long text labels display cleanly on the y-axis.
The categoryorder: "total ascending" setting sorts stations from fewest to most trips (bottom to top), so the busiest station lands at the top of the chart where the eye goes first.
- Save streamlit_app.py and click the Station Analysis tab in your browser.
- You should see a green horizontal bar chart with the top 15 stations listed by trip count.
Station Analysis tab is empty?
- Ensure the with tab2: block starts at the same indentation level as with tab1: (no extra indentation).
- Check that you are calling query_station_ranking (not query_daily_trips) inside tab2.
Help me fix my Station Analysis tab in Streamlit.
- Now add the Demographics tab code directly below the with tab2: block:
with tab3:
demo_df = query_demographics(selected_year)
if not demo_df.empty:
fig = px.bar(
demo_df,
x="usertype",
y="trip_count",
color="gender",
barmode="group",
title=f"Trips by User Type and Gender ({selected_year})",
labels={
"usertype": "User Type",
"trip_count": "Number of Trips",
"gender": "Gender",
},
)
fig.update_layout(height=450)
st.plotly_chart(fig, use_container_width=True)
else:
st.warning("No demographic data available.")
What does this code do?
The color="gender" parameter tells Plotly to split bars into groups by gender value. Combined with barmode="group", this places bars side-by-side rather than stacking them. Each user type gets its own cluster of gender-colored bars.
Notice this query only takes selected_year (not user type) because the chart already segments by user type on the x-axis. Filtering by user type here would defeat the purpose of the comparison.
- Save streamlit_app.py and click the Demographics tab in your browser.
- You should see a grouped bar chart with Subscriber and Customer on the x-axis, each split into colored bars by gender.
Chart shows no data or looks wrong?
- Verify you are calling query_demographics(selected_year) with only one argument. This function does not take user_type.
- If the chart appears but bars are stacked instead of grouped, check that barmode="group" is set inside px.bar().
Help me debug my Demographics grouped bar chart in Streamlit.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file and all three tabs render their charts.
ⓧ I'd like to double check the full code
Here is the complete streamlit_app.py at the end of this step:
import streamlit as st
from google.oauth2 import service_account
from google.cloud import bigquery
import plotly.express as px
import pandas as pd
# Page config
st.set_page_config(
page_title="NYC Citi Bike Analytics",
page_icon="\ud83d\udeb2",
layout="wide",
initial_sidebar_state="expanded",
)
# Custom CSS
st.markdown(
"""
<style>
[data-testid="stMetric"] {
background-color: #f0f2f6;
border: 1px solid #e0e3e8;
border-radius: 8px;
padding: 12px 16px;
}
[data-testid="stMetricLabel"] {
font-size: 0.85rem;
color: #555;
}
[data-testid="stMetricValue"] {
font-size: 1.6rem;
font-weight: 700;
}
.block-container {
padding-top: 1.5rem;
}
</style>
""",
unsafe_allow_html=True,
)
# BigQuery client
@st.cache_resource
def get_bigquery_client():
credentials = service_account.Credentials.from_service_account_info(
st.secrets["gcp_service_account"]
)
return bigquery.Client(credentials=credentials)
client = get_bigquery_client()
# Queries
@st.cache_data(ttl=600)
def query_daily_trips(year, user_type):
user_filter = ""
if user_type != "All":
user_filter = f"AND usertype = '{user_type}'"
query = f"""
SELECT
DATE(starttime) AS trip_date,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
{user_filter}
GROUP BY trip_date
ORDER BY trip_date
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_station_ranking(year, user_type):
user_filter = ""
if user_type != "All":
user_filter = f"AND usertype = '{user_type}'"
query = f"""
SELECT
start_station_name,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
AND start_station_name IS NOT NULL
{user_filter}
GROUP BY start_station_name
ORDER BY trip_count DESC
LIMIT 15
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_demographics(year):
query = f"""
SELECT
usertype,
gender,
COUNT(*) AS trip_count
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
AND usertype IS NOT NULL
AND gender != 'unknown'
GROUP BY usertype, gender
ORDER BY trip_count DESC
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_kpi_metrics(year):
query = f"""
SELECT
COUNT(*) AS total_trips,
AVG(tripduration) / 60.0 AS avg_duration_min,
COUNT(DISTINCT start_station_name) AS unique_stations
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {year}
"""
return client.query(query).to_dataframe()
@st.cache_data(ttl=600)
def query_kpi_previous_year(year):
prev_year = year - 1
query = f"""
SELECT
COUNT(*) AS total_trips
FROM `bigquery-public-data.new_york_citibike.citibike_trips`
WHERE EXTRACT(YEAR FROM starttime) = {prev_year}
"""
return client.query(query).to_dataframe()
# Sidebar
with st.sidebar:
st.title("Filters")
st.markdown("---")
selected_year = st.selectbox("Year", [2018, 2017, 2016, 2015, 2014], index=0)
selected_user_type = st.selectbox("User Type", ["All", "Subscriber", "Customer"])
st.markdown("---")
st.caption("Data: NYC Citi Bike (BigQuery Public Dataset)")
# Header
st.title("NYC Citi Bike Analytics Dashboard")
st.markdown(
f"Exploring **{selected_year}** trip data"
+ (f" for **{selected_user_type}** users" if selected_user_type != "All" else "")
)
# KPI Metrics
kpi_df = query_kpi_metrics(selected_year)
prev_df = query_kpi_previous_year(selected_year)
total_trips = int(kpi_df["total_trips"].iloc[0])
avg_duration = float(kpi_df["avg_duration_min"].iloc[0])
unique_stations = int(kpi_df["unique_stations"].iloc[0])
prev_trips = int(prev_df["total_trips"].iloc[0]) if not prev_df.empty else 0
yoy_delta = (
round((total_trips - prev_trips) / prev_trips * 100, 1) if prev_trips > 0 else 0
)
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric("Total Trips", f"{total_trips:,.0f}", delta=f"{yoy_delta}% YoY")
with col2:
st.metric("Avg Duration", f"{avg_duration:.1f} min")
with col3:
st.metric("Unique Stations", f"{unique_stations:,}")
with col4:
st.metric("Data Year", str(selected_year))
st.markdown("---")
# Tabbed charts
tab1, tab2, tab3 = st.tabs(["Trip Trends", "Station Analysis", "Demographics"])
with tab1:
daily_df = query_daily_trips(selected_year, selected_user_type)
if not daily_df.empty:
fig = px.line(
daily_df,
x="trip_date",
y="trip_count",
title=f"Daily Trip Volume ({selected_year})",
labels={"trip_date": "Date", "trip_count": "Number of Trips"},
)
fig.update_layout(hovermode="x unified", height=450)
fig.update_traces(line_color="#1f77b4")
st.plotly_chart(fig, use_container_width=True)
else:
st.warning("No data available for the selected filters.")
with tab2:
station_df = query_station_ranking(selected_year, selected_user_type)
if not station_df.empty:
fig = px.bar(
station_df,
x="trip_count",
y="start_station_name",
orientation="h",
title=f"Top 15 Stations by Trip Count ({selected_year})",
labels={
"trip_count": "Number of Trips",
"start_station_name": "Station",
},
)
fig.update_layout(yaxis={"categoryorder": "total ascending"}, height=500)
fig.update_traces(marker_color="#2ca02c")
st.plotly_chart(fig, use_container_width=True)
else:
st.warning("No station data available.")
with tab3:
demo_df = query_demographics(selected_year)
if not demo_df.empty:
fig = px.bar(
demo_df,
x="usertype",
y="trip_count",
color="gender",
barmode="group",
title=f"Trips by User Type and Gender ({selected_year})",
labels={
"usertype": "User Type",
"trip_count": "Number of Trips",
"gender": "Gender",
},
)
fig.update_layout(height=450)
st.plotly_chart(fig, use_container_width=True)
else:
st.warning("No demographic data available.")
Your dashboard is now a fully interactive analytics tool with KPIs, filters, and three chart tabs pulling live data from BigQuery. Next up, you will tackle the Secret Mission and add a geographic station map.
Secret mission
Add a Geographic Station Map
Your dashboard shows numbers and charts, but where are these stations physically located? In this secret mission, you'll add a geographic map visualization that plots every popular station on an interactive map, sized by trip volume and colored by average trip duration.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the BigQuery sandbox free tier, so there are no ongoing costs as long as you stay within the 1 TiB/month query limit.
Resources you used:
- Google Cloud project with BigQuery API enabled and a service account.
- Service account JSON key (stored locally in .streamlit/secrets.toml).
- Local project files (streamlit_app.py, requirements.txt, .gitignore, venv/ directory).
Keep everything running
No action needed. Choose this if you are still actively building or want to explore BigQuery further.
- Your Google Cloud project stays active at no cost within the BigQuery sandbox free tier.
- Your local files and virtual environment remain on your machine ready to use.
- To run the dashboard again later, activate your virtual environment and run streamlit run streamlit_app.py.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and cloud project stay intact for later.
- Stop the Streamlit dev server by pressing Ctrl+C in the terminal where it is running.
- Deactivate your virtual environment by running:
deactivate
- Your Google Cloud project and service account remain active at no cost.
- When you return, reactivate the virtual environment and start the Streamlit server again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your cloud project and local files permanently.
Delete your Google Cloud project:
- Open the Google Cloud Console.
- Navigate to IAM & Admin, then click Settings.
- Make sure your project is selected at the top of the page.
- Click Shut down.
- Enter your project ID in the confirmation dialog, then click Shut down anyway.
Delete your local project files:
- Stop the Streamlit server if it is still running (Ctrl+C).
- Navigate to your Desktop and delete the project folder by running:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
On Windows, use this command instead:
cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]
- Delete the downloaded service account JSON key file if you saved it outside the project folder (check your Downloads folder).
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-quality analytics dashboard that pulls live data from Google BigQuery and presents it through a polished, interactive interface.
You've learned how to:
- Connect a Streamlit frontend to Google BigQuery using service account authentication and a secrets.toml file for secure credential management.
- Write parameterized SQL queries with @st.cache_data caching, and wire them to interactive sidebar filters that dynamically update every dashboard section.
- Build a professional BI-style layout with KPI metric cards, year-over-year deltas, tabbed Plotly charts, and custom CSS styling.
- Secret Mission: Added a geographic scatter map visualization showing station locations sized by trip volume and colored by average duration.
Ready to quiz yourself?