Austin Animal Shelter Dashboard
Build a live Streamlit dashboard to identify long-stay animals using open data.
Introduction
30 Second Summary
Animal shelters process thousands of animals every year. Some animals end up staying far longer than others, and identifying these long-stay cases early helps staff prioritize care, outreach, and adoption efforts.
In this project, you will build a live analytics dashboard that pulls real shelter data from Austin Animal Center and highlights animals with extended stays.
What You'll Build
A Python Streamlit app that connects to Austin Animal Center's public API to identify long-stay animals, display KPI metrics, and visualize stay patterns with interactive charts.
By the end of this project, you'll have:
- A live Streamlit dashboard that fetches shelter data from Austin Animal Center's public REST API.
- A filterable watchlist table sorted by days in shelter, with sidebar controls for animal type, stay threshold, and outcome status.
- Interactive Plotly charts showing average stay by animal type and a histogram of stay durations.
- Secret Mission: Add an intake trend line chart with a rolling average to spot seasonal intake patterns over time.
Are there any prerequisites?
None. This guide includes full setup instructions for Python, Streamlit, and all required packages. No prior experience with dashboards or data analysis is needed.
Before We Start
Before We Start...
Set Up Your Python Environment
Before you can build your dashboard, you need a working Python environment with the right libraries installed.
In this step, you will install Python, create a project folder, set up a virtual environment, and confirm everything works by running a minimal Streamlit app.
In this step, get ready to:
- Install Python 3.11+ and verify it works.
- Create a project folder with a virtual environment and install dependencies.
- Run a minimal Streamlit app in your browser.
Install Python 3.11+
This project requires Python 3.11 or higher because one of the libraries (pandas 3.0.3) needs it.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Python version by running this command:
macOS
python3 --version
Windows
python --version
Look at the output in your terminal and pick the tab below that matches what you see.
✔️ I see version 3.11 or higher
You see something like Python 3.11.x or Python 3.12.x. You are good to go.
ⓧ I see an older version
If you see Python 3.9 or Python 3.10, you need to install a newer version. macOS ships with Python 3.9 by default, so this is common.
- Visit python.org/downloads and download the latest Python 3.11+ installer for your operating system.
- Run the installer. On Windows, check the box labelled Add Python to PATH before clicking Install Now.
- Close and reopen your terminal after installation.
- Run the version check command again to confirm you see 3.11 or higher.
macOS PATH note
On macOS, the system python3 may still point to the older version even after installing a new one. If python3 --version still shows 3.9, try running python3.11 --version or python3.12 --version to confirm the new install is available.
Use whichever command shows 3.11+ throughout this project in place of python3.
ⓧ Command not found
Python is not installed on your machine yet.
- Visit python.org/downloads and download the latest Python 3.11+ installer for your operating system.
- Run the installer. On Windows, check the box labelled Add Python to PATH before clicking Install Now.
- Close and reopen your terminal after installation.
- Run the version check command again to confirm you see 3.11 or higher.
Create your project folder and virtual environment
A virtual environment keeps your project's libraries isolated so they do not conflict with other Python projects on your machine.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir shelter-watchlist
cd shelter-watchlist
- Create a virtual environment by running this command:
python3 -m venv venv
What does this command do?
python3 -m venv venv tells Python to create a new virtual environment in a folder called venv inside your project directory. All your installed packages will live there instead of being installed globally.
- Activate the virtual environment by running:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell, then try the activate command again.
On macOS, make sure you are in the shelter-watchlist folder (run pwd to check). The venv folder must exist in that directory.
Still stuck? Help me activate my Python virtual environment
- Install the three project dependencies by running:
pip install streamlit==1.58.0 pandas==3.0.3 plotly==6.8.0
What are these libraries?
- streamlit is the framework that turns your Python script into an interactive web dashboard.
- pandas handles data loading, filtering, and transformation.
- plotly creates interactive charts (bar charts, histograms) you can hover over and zoom.
The install may take a minute. When it finishes, you should see a Successfully installed message listing all the packages.
Install failing?
Make sure (venv) is showing in your prompt. If you skipped activation, pip may point to a system Python that is too old.
If you see an error mentioning a missing wheel or incompatible version, confirm your Python is 3.11+ by running python3 --version inside the activated environment.
Help me fix a pip install error
Create and run a minimal Streamlit app
Now you will create a tiny app file to confirm Streamlit runs in your browser.
- Open VS Code (or Cursor). Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or Cursor) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the shelter-watchlist folder on your Desktop, then click Open.
- Create a new file called app.py inside the shelter-watchlist folder. In the Explorer sidebar, right-click and select New File, then type app.py.
- Add the following code to app.py:
import streamlit as st
st.title("Hello")
What does this code do?
import streamlit as st loads the Streamlit library so you can use its functions.
st.title("Hello") displays a large heading on the web page. This is the simplest possible Streamlit app.
- Save app.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Switch back to your terminal (make sure (venv) is still showing) and run the app:
streamlit run app.py
What should I see?
Streamlit will print a local URL (usually http://localhost:8501) and automatically open your default browser. You should see a page with the heading "Hello" displayed at the top.
If the browser did not open automatically, copy the URL from the terminal and paste it into your browser address bar.
Your browser should show a page with a large "Hello" heading. This confirms Streamlit is installed and running correctly.
Browser not opening or seeing an error?
Make sure you saved app.py before running the command. Streamlit reads the file from disk.
If you see command not found: streamlit, your virtual environment is not active. Reactivate it and try again.
Help me get Streamlit running
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and can see "Hello" in your browser at localhost:8501.
ⓧ I'd like to double check the full code
Here is what app.py should contain at this point:
import streamlit as st
st.title("Hello")
Your Python environment is ready and Streamlit is running. Next up, you will connect your dashboard to live shelter data from Austin Animal Center's public API.
Fetch Live Data from the API
Your Python environment is ready and Streamlit is running locally. Now it's time to connect your dashboard to real data.
Austin Animal Center publishes live shelter data through a public API. In this step, you'll write a function that pulls thousands of records from that API and prepares them for analysis.
In this step, get ready to:
- Connect to Austin Animal Center's public Socrata API.
- Cache the data so your dashboard stays responsive.
- Parse dates and numeric fields for analysis.
Replace the starter code with your page layout
Your dashboard needs a proper page configuration before you start pulling data. This replaces the minimal "Hello" app with the real dashboard shell.
- In VS Code, open app.py and select all the existing code (Cmd+A on macOS, Ctrl+A on Windows).
- Replace it with the following code:
import streamlit as st
import pandas as pd
import plotly.express as px
# Configure the page to use the full browser width
st.set_page_config(page_title="Austin Animal Shelter Watchlist", layout="wide")
st.title("Austin Animal Shelter Long-Stay Watchlist")
st.markdown("Identify animals with extended shelter stays using live data from Austin Animal Center.")
What does this code do?
- import pandas as pd brings in the pandas library for data manipulation.
- import plotly.express as px imports Plotly for charting (used in a later step).
- st.set_page_config(layout="wide") makes the dashboard use the full browser width instead of a narrow centered column.
- st.title() and st.markdown() render the dashboard heading and a short description.
- Save app.py.
- Switch to your browser at localhost:8501. If Streamlit asks whether to rerun, choose Always rerun in the upper-right corner.
- You should see "Austin Animal Shelter Long-Stay Watchlist" as the page title with a description below it.
Still seeing 'Hello'?
- Make sure you deleted all the old code before pasting. The file should no longer contain st.title("Hello").
- Check that you saved the file after pasting the new code.
Help me figure out why my Streamlit page still shows the old title.
Write the cached data-loading function
Austin Animal Center's API returns shelter records as JSON. You'll use pandas to read that JSON directly from the URL into a DataFrame, then clean up the date and numeric fields.
Wrapping the function in a cache decorator means Streamlit only fetches fresh data once per hour, not on every page interaction.
- In app.py, add the following code below the st.markdown(...) line:
@st.cache_data(ttl=3600)
def load_data():
# Build the API URL with query parameters
url = (
"https://data.austintexas.gov/resource/gsvs-ypi7.json"
"?$limit=5000"
"&$where=days_in_shelter IS NOT NULL"
)
# Fetch JSON data directly into a DataFrame
df = pd.read_json(url)
# Parse date columns for time-based calculations
df["intake_date"] = pd.to_datetime(df["intake_date"], errors="coerce")
df["outcome_date"] = pd.to_datetime(df["outcome_date"], errors="coerce")
df["date_of_birth"] = pd.to_datetime(df["date_of_birth"], errors="coerce")
# Convert days_in_shelter from string to number
df["days_in_shelter"] = pd.to_numeric(df["days_in_shelter"], errors="coerce")
return df
# Call the function and display record count
df = load_data()
st.write(f"Loaded {len(df):,} records")
What does this code do?
- @st.cache_data(ttl=3600) tells Streamlit to store the function's result for 3600 seconds (1 hour). Subsequent reruns skip the network call and use the cached copy.
- The URL uses Socrata query parameters: $limit=5000 fetches up to 5000 records, and $where=days_in_shelter IS NOT NULL filters out records that have no stay data.
- pd.read_json(url) sends a GET request to the API and parses the JSON response directly into a pandas DataFrame.
- pd.to_datetime(..., errors="coerce") converts string dates into proper datetime objects. Invalid values become NaT (Not a Time) instead of crashing.
- pd.to_numeric(..., errors="coerce") converts the days_in_shelter field from a string to a number. The API returns this field as text.
Why cache the data?
Streamlit reruns your entire script from top to bottom every time you interact with a widget. Without caching, each click would trigger a fresh API call and add seconds of latency.
The ttl=3600 parameter sets a time-to-live of one hour. After that, the next interaction fetches fresh data automatically.
- Save app.py.
- Switch to your browser. You should see "Loaded 5,000 records" (or a similar number) displayed below the title.
Seeing an error instead of a record count?
- If you see a ConnectionError or timeout, check your internet connection. The app needs to reach the Austin open data server.
- If you see NameError: name 'pd' is not defined, make sure the import pandas as pd line is at the top of your file.
- If the page loads but shows 0 records, check that the URL string is on multiple lines joined by parentheses with no spaces before the ? and & characters.
Help me debug my Streamlit data-loading function.
✔️ Awesome, I've got everything!
Great. Double check you've saved app.py and your browser shows the record count.
ⓧ I'd like to double check the full code
Here is the complete app.py at this point:
import streamlit as st
import pandas as pd
import plotly.express as px
st.set_page_config(page_title="Austin Animal Shelter Watchlist", layout="wide")
st.title("Austin Animal Shelter Long-Stay Watchlist")
st.markdown("Identify animals with extended shelter stays using live data from Austin Animal Center.")
@st.cache_data(ttl=3600)
def load_data():
url = (
"https://data.austintexas.gov/resource/gsvs-ypi7.json"
"?$limit=5000"
"&$where=days_in_shelter IS NOT NULL"
)
df = pd.read_json(url)
df["intake_date"] = pd.to_datetime(df["intake_date"], errors="coerce")
df["outcome_date"] = pd.to_datetime(df["outcome_date"], errors="coerce")
df["date_of_birth"] = pd.to_datetime(df["date_of_birth"], errors="coerce")
df["days_in_shelter"] = pd.to_numeric(df["days_in_shelter"], errors="coerce")
return df
df = load_data()
st.write(f"Loaded {len(df):,} records")
Your dashboard is now pulling live shelter data from Austin Animal Center's API and caching it for fast interactions. Next up, you'll add sidebar filters and KPI metrics so users can explore this data interactively.
Build Sidebar Filters and KPI Metrics
Your app can now pull live shelter data and display it on screen. That's the raw material, but a wall of 5,000 records doesn't help anyone make decisions.
In this step, you'll build interactive sidebar controls that let users slice the dataset by animal type, outcome status, and a configurable stay threshold. You'll then compute and display key performance indicators (KPIs) that summarize the shelter's current state at a glance.
In this step, get ready to:
- Add sidebar filter controls for animal type, stay threshold, and outcome status.
- Apply those filters to compute KPI metrics from the live data.
- Display the KPIs in a row of metric cards that update in real time.
Add sidebar filter controls
Streamlit provides a built-in sidebar panel via st.sidebar. Any widget you place there stays visible while the main content scrolls, giving users persistent filter access.
- In app.py, find this line near the bottom of the file:
st.write(f"Loaded {len(df):,} records")
- Delete that line and replace it with the following sidebar code:
# Add a header to the sidebar
st.sidebar.header("Filters")
# Build a multiselect for animal type using unique values from the data
animal_types = df["type"].dropna().unique().tolist()
selected_types = st.sidebar.multiselect(
"Animal Type", options=animal_types, default=animal_types
)
# Number input for the long-stay day threshold
threshold = st.sidebar.number_input(
"Long-Stay Threshold (days)", min_value=1, max_value=365, value=30
)
# Build a multiselect for outcome status using unique values from the data
outcome_statuses = df["outcome_status"].dropna().unique().tolist()
selected_statuses = st.sidebar.multiselect(
"Outcome Status", options=outcome_statuses, default=outcome_statuses
)
What does this code do?
- st.sidebar.header("Filters") creates a titled section in the left sidebar panel.
- st.sidebar.multiselect creates a dropdown that lets users pick multiple values. Setting default=animal_types starts with all options selected.
- st.sidebar.number_input gives users a numeric field with min/max bounds. The default of 30 means animals staying longer than 30 days will be flagged as "long-stay."
- dropna().unique().tolist() extracts the distinct values from the column, skipping any missing entries, so the dropdown only shows real options.
- Save app.py and check your browser.
- You should see a left sidebar panel with a "Filters" header, two multiselect dropdowns (Animal Type and Outcome Status), and a number input defaulting to 30.
Don't see the sidebar?
- Make sure you deleted the entire st.write(...) line. Leftover fragments cause syntax errors.
- Check that the new code starts immediately after df = load_data() with one blank line separating them.
- If the sidebar is collapsed, click the arrow icon in the top-left corner of the page to expand it.
Help me debug my Streamlit sidebar code. The sidebar isn't appearing in my browser.
Apply filters and calculate KPI metrics
The sidebar widgets now store the user's selections in variables. Next you'll use those selections to filter the DataFrame and compute three summary metrics.
- Add the following code directly below the sidebar section you just wrote:
# Apply sidebar selections to filter the DataFrame
filtered_df = df[
(df["type"].isin(selected_types)) & (df["outcome_status"].isin(selected_statuses))
]
# Calculate KPI values from the filtered data
total_animals = len(filtered_df)
avg_stay = filtered_df["days_in_shelter"].mean()
long_stay_count = len(filtered_df[filtered_df["days_in_shelter"] > threshold])
What does this code do?
- filtered_df is a new pandas DataFrame containing only the rows that match the user's sidebar selections for animal type AND outcome status.
- total_animals counts all rows in the filtered set. avg_stay computes the mean days in shelter across those rows.
- long_stay_count counts only animals whose days_in_shelter exceeds the user's threshold. Every time the user changes the threshold input, Streamlit reruns the script and this number recalculates automatically.
You won't see a visible change yet. The next chunk displays these computed values as metric cards.
Display KPI metrics in a row
Streamlit's st.columns splits the page into side-by-side regions. Combined with st.metric, this creates a clean KPI card layout at the top of your dashboard.
- Add the following code directly below the KPI calculations:
# Display KPI metrics in a three-column row
col1, col2, col3 = st.columns(3)
col1.metric("Total Animals", f"{total_animals:,}")
col2.metric("Avg Days in Shelter", f"{avg_stay:.0f}" if pd.notna(avg_stay) else "N/A")
col3.metric("Long-Stay Animals", f"{long_stay_count:,}")
What does this code do?
- st.columns(3) creates three equal-width columns. Each column object (col1, col2, col3) acts like a mini page you can write widgets into.
- .metric() renders a large-font card with a label and value. The format strings (:, for thousands separators, :.0f for no decimals) make numbers easy to read.
- The pd.notna(avg_stay) check prevents a crash if no data matches the current filters. It shows "N/A" instead of erroring.
- Save app.py and check your browser.
- You should see three metric cards at the top of the main content area showing "Total Animals", "Avg Days in Shelter", and "Long-Stay Animals" with numeric values.
- Try changing the Long-Stay Threshold (days) value in the sidebar. The "Long-Stay Animals" count should update immediately.
Why do the metrics update automatically?
Streamlit uses a rerun model. Every time a widget value changes, the entire script runs top-to-bottom with the new values. Your cached data function skips the API call (thanks to @st.cache_data), but the filtering and metric calculations run fresh with the updated threshold.
Metrics showing unexpected values?
- If "Long-Stay Animals" stays at 0, make sure you used > threshold (greater than), not >= threshold or < threshold.
- If you see "N/A" for average stay, check that your multiselects still have at least one option selected. Deselecting all animal types gives an empty DataFrame.
- If the page shows an error, make sure the metric display code is below the KPI calculation lines. Streamlit runs top-to-bottom, so variables must be defined before you use them.
Help me debug my KPI metrics. The values don't look right when I change the sidebar filters.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, then move on to the next step.
ⓧ I'd like to double check the full code
Here's the complete app.py so far. Compare yours against it line by line.
import streamlit as st
import pandas as pd
import plotly.express as px
st.set_page_config(page_title="Austin Animal Shelter Watchlist", layout="wide")
st.title("Austin Animal Shelter Long-Stay Watchlist")
st.markdown("Identify animals with extended shelter stays using live data from Austin Animal Center.")
@st.cache_data(ttl=3600)
def load_data():
url = (
"https://data.austintexas.gov/resource/gsvs-ypi7.json"
"?$limit=5000"
"&$where=days_in_shelter IS NOT NULL"
)
df = pd.read_json(url)
df["intake_date"] = pd.to_datetime(df["intake_date"], errors="coerce")
df["outcome_date"] = pd.to_datetime(df["outcome_date"], errors="coerce")
df["date_of_birth"] = pd.to_datetime(df["date_of_birth"], errors="coerce")
df["days_in_shelter"] = pd.to_numeric(df["days_in_shelter"], errors="coerce")
return df
df = load_data()
st.sidebar.header("Filters")
animal_types = df["type"].dropna().unique().tolist()
selected_types = st.sidebar.multiselect(
"Animal Type", options=animal_types, default=animal_types
)
threshold = st.sidebar.number_input(
"Long-Stay Threshold (days)", min_value=1, max_value=365, value=30
)
outcome_statuses = df["outcome_status"].dropna().unique().tolist()
selected_statuses = st.sidebar.multiselect(
"Outcome Status", options=outcome_statuses, default=outcome_statuses
)
filtered_df = df[
(df["type"].isin(selected_types)) & (df["outcome_status"].isin(selected_statuses))
]
total_animals = len(filtered_df)
avg_stay = filtered_df["days_in_shelter"].mean()
long_stay_count = len(filtered_df[filtered_df["days_in_shelter"] > threshold])
col1, col2, col3 = st.columns(3)
col1.metric("Total Animals", f"{total_animals:,}")
col2.metric("Avg Days in Shelter", f"{avg_stay:.0f}" if pd.notna(avg_stay) else "N/A")
col3.metric("Long-Stay Animals", f"{long_stay_count:,}")
Your dashboard now has live filtering and at-a-glance KPIs. Next up, you'll build the watchlist table that highlights the specific animals behind those numbers.
Create the Long-Stay Watchlist Table
Your dashboard now shows high-level KPI metrics, but shelter staff need a way to see exactly which animals have been waiting the longest.
In this step, you'll build a filterable watchlist table that highlights individual animals exceeding the long-stay threshold. You'll also add a download button so staff can export the list as a CSV file.
In this step, get ready to:
- Filter and sort the data to isolate long-stay animals.
- Display a formatted interactive table with human-friendly column labels.
- Add a CSV download button for exporting the watchlist.
Filter and sort the watchlist data
The KPI card already counts how many animals exceed the threshold. Now you need the actual rows so you can display them in a table.
- In app.py, find the last line of the KPI section:
col3.metric("Long-Stay Animals", f"{long_stay_count:,}")
- Add the following code below that line to create a subheader and filter the watchlist data:
st.subheader(f"Long-Stay Watchlist (>{threshold} days)")
# Filter for animals exceeding the threshold and sort longest-stay first
watchlist_df = filtered_df[filtered_df["days_in_shelter"] > threshold].sort_values(
"days_in_shelter", ascending=False
)
What does this code do?
The f-string in st.subheader inserts the user's chosen threshold directly into the heading, so it stays in sync with the sidebar.
The filter keeps only rows where days_in_shelter exceeds the threshold. Sorting descending puts the animals waiting longest right at the top of the list.
- Save app.py.
- Refresh your browser at localhost:8501. You should see a new subheader reading "Long-Stay Watchlist (>30 days)" below the metrics row.
Don't see the new subheader?
Make sure you saved app.py. Streamlit auto-refreshes on save, but only if the file is actually written to disk.
Check that the new code is placed after the col3.metric line, not inside the load_data() function.
Help me debug my Streamlit subheader not appearing
Display the watchlist as an interactive table
Raw DataFrame column names like primary_breed are not reader-friendly. Streamlit's column configuration lets you rename columns and set display types without modifying the underlying data.
- Add the following code below the watchlist_df sort to define which columns to show and render the table:
# Define which columns to display in the table
display_columns = [
"animal_id",
"name",
"type",
"primary_breed",
"days_in_shelter",
"intake_date",
"outcome_status",
]
# Only keep columns that actually exist in the data
available_columns = [col for col in display_columns if col in watchlist_df.columns]
st.dataframe(
watchlist_df[available_columns].reset_index(drop=True),
use_container_width=True,
hide_index=True,
column_config={
"animal_id": st.column_config.TextColumn("Animal ID"),
"name": st.column_config.TextColumn("Name"),
"type": st.column_config.TextColumn("Type"),
"primary_breed": st.column_config.TextColumn("Breed"),
"days_in_shelter": st.column_config.NumberColumn("Days in Shelter"),
"intake_date": st.column_config.DateColumn("Intake Date", format="YYYY-MM-DD"),
"outcome_status": st.column_config.TextColumn("Outcome"),
},
)
What does this code do?
- display_columns defines the ideal columns in the order you want them shown.
- available_columns filters that list to only include columns actually present in the DataFrame. This prevents a crash if the API ever drops a field.
- column_config maps each raw column name to a human-friendly label and data type. DateColumn formats dates consistently as YYYY-MM-DD.
- use_container_width=True stretches the table to fill the page width, and hide_index=True removes the default row numbers.
- Save app.py and refresh your browser.
- You should see an interactive table showing only animals with more than 30 days in shelter, sorted with the longest stay at the top. Column headers should read "Animal ID", "Name", "Type", "Breed", "Days in Shelter", "Intake Date", and "Outcome".
Seeing a KeyError or empty table?
If you see a KeyError, double-check that the column names in display_columns match the API field names exactly (lowercase with underscores).
If the table appears empty, try lowering the threshold in the sidebar to 5 or 10 to confirm the filter logic works.
Help me fix my watchlist table not rendering
Add a CSV download button
Shelter staff may need to share this watchlist with colleagues who don't have access to the dashboard. A download button lets them export the filtered data instantly.
- Add the following code below the st.dataframe call to create the export button:
# Convert the watchlist to CSV and offer a download button
csv = watchlist_df[available_columns].to_csv(index=False)
st.download_button(
label="Download Watchlist CSV",
data=csv,
file_name="long_stay_watchlist.csv",
mime="text/csv",
)
What does this code do?
The .to_csv(index=False) converts the filtered DataFrame into a CSV string without row numbers.
st.download_button renders a clickable button that triggers a browser download. The mime parameter tells the browser this is a CSV file so it opens in a spreadsheet app.
- Save app.py and refresh your browser.
- You should see a "Download Watchlist CSV" button below the table. Click it to confirm a file named long_stay_watchlist.csv downloads to your machine.
Download button not appearing?
Make sure the download button code is outside and below the st.dataframe() call, not nested inside it.
If you see a NameError for available_columns, confirm the variable definition appears earlier in the file, before both the dataframe and the download button.
Help me fix my Streamlit download button
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete app.py up to this point. Compare it against yours:
import streamlit as st
import pandas as pd
import plotly.express as px
st.set_page_config(page_title="Austin Animal Shelter Watchlist", layout="wide")
st.title("Austin Animal Shelter Long-Stay Watchlist")
st.markdown("Identify animals with extended shelter stays using live data from Austin Animal Center.")
@st.cache_data(ttl=3600)
def load_data():
url = (
"https://data.austintexas.gov/resource/gsvs-ypi7.json"
"?$limit=5000"
"&$where=days_in_shelter IS NOT NULL"
)
df = pd.read_json(url)
df["intake_date"] = pd.to_datetime(df["intake_date"], errors="coerce")
df["outcome_date"] = pd.to_datetime(df["outcome_date"], errors="coerce")
df["date_of_birth"] = pd.to_datetime(df["date_of_birth"], errors="coerce")
df["days_in_shelter"] = pd.to_numeric(df["days_in_shelter"], errors="coerce")
return df
df = load_data()
st.sidebar.header("Filters")
animal_types = df["type"].dropna().unique().tolist()
selected_types = st.sidebar.multiselect(
"Animal Type", options=animal_types, default=animal_types
)
threshold = st.sidebar.number_input(
"Long-Stay Threshold (days)", min_value=1, max_value=365, value=30
)
outcome_statuses = df["outcome_status"].dropna().unique().tolist()
selected_statuses = st.sidebar.multiselect(
"Outcome Status", options=outcome_statuses, default=outcome_statuses
)
filtered_df = df[
(df["type"].isin(selected_types)) & (df["outcome_status"].isin(selected_statuses))
]
total_animals = len(filtered_df)
avg_stay = filtered_df["days_in_shelter"].mean()
long_stay_count = len(filtered_df[filtered_df["days_in_shelter"] > threshold])
col1, col2, col3 = st.columns(3)
col1.metric("Total Animals", f"{total_animals:,}")
col2.metric("Avg Days in Shelter", f"{avg_stay:.0f}" if pd.notna(avg_stay) else "N/A")
col3.metric("Long-Stay Animals", f"{long_stay_count:,}")
st.subheader(f"Long-Stay Watchlist (>{threshold} days)")
watchlist_df = filtered_df[filtered_df["days_in_shelter"] > threshold].sort_values(
"days_in_shelter", ascending=False
)
display_columns = [
"animal_id",
"name",
"type",
"primary_breed",
"days_in_shelter",
"intake_date",
"outcome_status",
]
available_columns = [col for col in display_columns if col in watchlist_df.columns]
st.dataframe(
watchlist_df[available_columns].reset_index(drop=True),
use_container_width=True,
hide_index=True,
column_config={
"animal_id": st.column_config.TextColumn("Animal ID"),
"name": st.column_config.TextColumn("Name"),
"type": st.column_config.TextColumn("Type"),
"primary_breed": st.column_config.TextColumn("Breed"),
"days_in_shelter": st.column_config.NumberColumn("Days in Shelter"),
"intake_date": st.column_config.DateColumn("Intake Date", format="YYYY-MM-DD"),
"outcome_status": st.column_config.TextColumn("Outcome"),
},
)
csv = watchlist_df[available_columns].to_csv(index=False)
st.download_button(
label="Download Watchlist CSV",
data=csv,
file_name="long_stay_watchlist.csv",
mime="text/csv",
)
Your watchlist is now a focused, actionable view of the animals that need the most attention. Next up, you'll add interactive Plotly charts to visualize stay patterns across the entire shelter population.
Visualize Stay Patterns with Plotly Charts
Your watchlist table gives shelter staff a clear view of which animals need attention. Now let's add visual context so they can spot patterns at a glance.
Plotly Express creates interactive charts with just a few lines of Python. You'll build a bar chart comparing average stays across animal types and a histogram showing how stay durations are distributed.
In this step, get ready to:
- Create a bar chart showing average days in shelter by animal type.
- Create a histogram of stay duration distribution.
- Display both charts side by side with interactive hover and zoom.
Create the average stay bar chart
A bar chart makes it easy to compare a single metric across categories. Here you'll group your filtered data by animal type and plot the average number of days each type spends in the shelter.
- In app.py, scroll to the bottom of the file (below the st.download_button block).
- Add the two-column layout and bar chart by pasting this code at the end of the file:
# Create a two-column layout for charts
left, right = st.columns(2)
with left:
st.subheader("Average Stay by Animal Type")
# Group filtered data by animal type and compute mean stay
avg_by_type = (
filtered_df.groupby("type")["days_in_shelter"].mean().reset_index()
)
# Build a bar chart with Plotly Express
fig_bar = px.bar(
avg_by_type,
x="type",
y="days_in_shelter",
labels={"type": "Animal Type", "days_in_shelter": "Avg Days in Shelter"},
)
st.plotly_chart(fig_bar, use_container_width=True)
What does this code do?
left, right = st.columns(2) creates two equal-width columns so you can place charts side by side.
Inside the left column, groupby("type") splits the filtered animals by type (Dog, Cat, etc.) and .mean() calculates the average shelter stay for each group. px.bar() then plots the result as an interactive bar chart.
- Save app.py.
- Switch back to your browser at localhost:8501. You should see a bar chart on the left side of the page below the watchlist table, with bars for each animal type.
Don't see the bar chart?
- Make sure the new code is pasted below the st.download_button block, not inside it.
- Check your indentation. The code inside with left: must be indented one level (4 spaces).
- If your terminal shows an error, check that import plotly.express as px is still at the top of your file.
Why isn't my Plotly bar chart showing up in Streamlit?
Create the stay duration histogram
The bar chart shows averages, but averages can hide outliers. A histogram reveals the full shape of the data by showing how many animals fall into each duration bucket.
- In app.py, find the closing line of the with left: block (the st.plotly_chart(fig_bar, use_container_width=True) line).
- Add the histogram in the right column by pasting this code directly below:
with right:
st.subheader("Stay Duration Distribution")
# Build a histogram showing how stay durations are spread
fig_hist = px.histogram(
filtered_df,
x="days_in_shelter",
nbins=30,
labels={"days_in_shelter": "Days in Shelter"},
)
st.plotly_chart(fig_hist, use_container_width=True)
What does this code do?
px.histogram() automatically bins the days_in_shelter values into 30 buckets and counts how many animals fall into each range.
This gives you a picture of the distribution. A spike near zero means most animals leave quickly. A long tail to the right means a few animals stay much longer than average.
- Save app.py and refresh your browser.
- You should now see two charts side by side. The bar chart is on the left and the histogram is on the right.
- Hover over any bar or bin to see exact values. Try zooming in by click-dragging on either chart.
- Change a filter in the sidebar (for example, deselect Cat from Animal Type). Both charts update instantly because they draw from filtered_df.
Charts not updating when you change filters?
- Make sure both chart blocks use filtered_df (not df). The unfiltered df ignores sidebar selections.
- If both charts appear stacked vertically instead of side by side, check that the histogram code starts with with right: at the same indentation level as with left:.
Why aren't my Streamlit charts responding to sidebar filter changes?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and both charts are rendering interactively.
ⓧ I'd like to double check the full code
Here's the complete app.py file. Compare yours against it line by line.
import streamlit as st
import pandas as pd
import plotly.express as px
st.set_page_config(page_title="Austin Animal Shelter Watchlist", layout="wide")
st.title("Austin Animal Shelter Long-Stay Watchlist")
st.markdown("Identify animals with extended shelter stays using live data from Austin Animal Center.")
@st.cache_data(ttl=3600)
def load_data():
url = (
"https://data.austintexas.gov/resource/gsvs-ypi7.json"
"?$limit=5000"
"&$where=days_in_shelter IS NOT NULL"
)
df = pd.read_json(url)
df["intake_date"] = pd.to_datetime(df["intake_date"], errors="coerce")
df["outcome_date"] = pd.to_datetime(df["outcome_date"], errors="coerce")
df["date_of_birth"] = pd.to_datetime(df["date_of_birth"], errors="coerce")
df["days_in_shelter"] = pd.to_numeric(df["days_in_shelter"], errors="coerce")
return df
df = load_data()
st.sidebar.header("Filters")
animal_types = df["type"].dropna().unique().tolist()
selected_types = st.sidebar.multiselect(
"Animal Type", options=animal_types, default=animal_types
)
threshold = st.sidebar.number_input(
"Long-Stay Threshold (days)", min_value=1, max_value=365, value=30
)
outcome_statuses = df["outcome_status"].dropna().unique().tolist()
selected_statuses = st.sidebar.multiselect(
"Outcome Status", options=outcome_statuses, default=outcome_statuses
)
filtered_df = df[
(df["type"].isin(selected_types)) & (df["outcome_status"].isin(selected_statuses))
]
total_animals = len(filtered_df)
avg_stay = filtered_df["days_in_shelter"].mean()
long_stay_count = len(filtered_df[filtered_df["days_in_shelter"] > threshold])
col1, col2, col3 = st.columns(3)
col1.metric("Total Animals", f"{total_animals:,}")
col2.metric("Avg Days in Shelter", f"{avg_stay:.0f}" if pd.notna(avg_stay) else "N/A")
col3.metric("Long-Stay Animals", f"{long_stay_count:,}")
st.subheader(f"Long-Stay Watchlist (>{threshold} days)")
watchlist_df = filtered_df[filtered_df["days_in_shelter"] > threshold].sort_values(
"days_in_shelter", ascending=False
)
display_columns = [
"animal_id",
"name",
"type",
"primary_breed",
"days_in_shelter",
"intake_date",
"outcome_status",
]
available_columns = [col for col in display_columns if col in watchlist_df.columns]
st.dataframe(
watchlist_df[available_columns].reset_index(drop=True),
use_container_width=True,
hide_index=True,
column_config={
"animal_id": st.column_config.TextColumn("Animal ID"),
"name": st.column_config.TextColumn("Name"),
"type": st.column_config.TextColumn("Type"),
"primary_breed": st.column_config.TextColumn("Breed"),
"days_in_shelter": st.column_config.NumberColumn("Days in Shelter"),
"intake_date": st.column_config.DateColumn("Intake Date", format="YYYY-MM-DD"),
"outcome_status": st.column_config.TextColumn("Outcome"),
},
)
csv = watchlist_df[available_columns].to_csv(index=False)
st.download_button(
label="Download Watchlist CSV",
data=csv,
file_name="long_stay_watchlist.csv",
mime="text/csv",
)
left, right = st.columns(2)
with left:
st.subheader("Average Stay by Animal Type")
avg_by_type = (
filtered_df.groupby("type")["days_in_shelter"].mean().reset_index()
)
fig_bar = px.bar(
avg_by_type,
x="type",
y="days_in_shelter",
labels={"type": "Animal Type", "days_in_shelter": "Avg Days in Shelter"},
)
st.plotly_chart(fig_bar, use_container_width=True)
with right:
st.subheader("Stay Duration Distribution")
fig_hist = px.histogram(
filtered_df,
x="days_in_shelter",
nbins=30,
labels={"days_in_shelter": "Days in Shelter"},
)
st.plotly_chart(fig_hist, use_container_width=True)
Secret mission
Add an Intake Trend Line with Rolling Average
Your dashboard shows a snapshot of the shelter right now. But what about the bigger picture? In this secret mission, add a time-series chart that reveals intake volume over time, smoothed with a rolling average to expose seasonal patterns.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- A running Streamlit dev server on localhost:8501.
- The project folder on your Desktop containing app.py and the venv virtual environment.
- Installed Python packages (streamlit, pandas, plotly) inside the virtual environment.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your dashboard.
- Your Streamlit server stays running at localhost:8501.
- Your project folder and virtual environment remain on your Desktop.
- There are no costs associated with keeping everything in place.
Pause - I'll come back to this later
Stop the running Streamlit server to free up your terminal. Your files stay on disk so you can pick up where you left off.
- Switch to the terminal where Streamlit is running.
- Press Ctrl+C to stop the server.
- When you're ready to resume, activate the virtual environment again and run streamlit run app.py.
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes your code, virtual environment, and all installed packages.
- Switch to the terminal where Streamlit is running and press Ctrl+C to stop the server.
- Deactivate the virtual environment by running:
deactivate
- Navigate to your Desktop and delete the project folder by running:
macOS
cd ~/Desktop
rm -rf austin-shelter-dashboard
Windows
cd ~\Desktop
rmdir /s /q austin-shelter-dashboard
- This removes app.py, the venv folder, and all installed packages in one step.
Nice Work!
Nice Work!
You just built a live analytics dashboard that pulls real shelter data from Austin Animal Center's public API and surfaces actionable insights about long-stay animals.
You've learned how to:
- Built a live data pipeline from Austin Animal Center's public Socrata API to a local Streamlit dashboard, fetching and caching 5000 records with @st.cache_data.
- Transformed raw shelter data with pandas: parsed date columns, filtered by a configurable threshold, and computed aggregate KPI metrics.
- Visualized stay patterns with Plotly Express bar charts and histograms, rendered side by side with interactive hover and zoom.
- Secret Mission: Added a time-series trend chart with a 3-month rolling average showing intake patterns over time.
Ready to quiz yourself?