Austin Economic Change Tracker

Build a Python pipeline querying Census APIs to map Austin ZIP economic trends.

Introduction

30 Second Summary

When a city council debates rezoning a neighborhood, the most persuasive arguments are backed by data. Knowing which ZIP codes gained businesses, lost jobs, or shifted industries over the past decade turns speculation into evidence.

In this project, you will build a Python data pipeline that pulls multi-year business data from the U.S. Census Bureau for four Austin ZIP codes and outputs a GeoJSON file enriched with economic change metrics.

What You'll Build

A Python script that queries the Census Bureau County Business Patterns API across multiple years, merges the results with geographic boundary polygons, and exports a single GeoJSON file showing economic trends per ZIP code.

By the end of this project, you'll have:

  • A working Python script that pulls multi-year business establishment data from the Census Bureau API for specific Austin ZIP codes.
  • A GeoJSON output file combining ZCTA boundary polygons with computed economic change metrics like establishment growth, employment shifts, and sector trends.
  • A reusable data pipeline pattern for querying any ZIP code over any year range to inform rezoning analysis.
  • Secret Mission: Add year-over-year trend scoring to classify each ZIP's economic trajectory as accelerating, stable, declining, or volatile.

Are there any prerequisites?

You should be comfortable with Python and the command line. No prior experience with geospatial data or the Census API is needed.

Set Up Your Environment and API Key

Your data pipeline needs three things before it can query the Census Bureau: Python libraries for geospatial processing, a free API key to authenticate requests, and a project file to hold everything together.

In this step, you will install the required packages, sign up for a Census API key, and create the script that will power the rest of this project.

In this step, get ready to:
  • Install the geopandas, requests, and pandas packages.
  • Obtain a free Census Bureau API key.
  • Create your project file with verified imports and configuration constants.
Install the required Python packages

Your script relies on three libraries. GeoPandas handles geographic data and shapefile operations. requests makes HTTP calls to the Census API. pandas structures the tabular data you pull back.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir austin-econ-tracker
cd austin-econ-tracker
  • Install all three packages by running:
pip install geopandas requests pandas

What does this command do?

This installs geopandas (for reading shapefiles and exporting GeoJSON), requests (for making HTTP requests to the Census API), and pandas (for organizing data in DataFrames).

GeoPandas also installs several geographic dependencies automatically, so the installation may take a minute.

  • Confirm the install succeeded. You should see Successfully installed messages near the end of the terminal output listing each package.

Seeing 'command not found' for pip?

  • Try pip3 install geopandas requests pandas instead. On some macOS setups, pip points to Python 2 while pip3 targets Python 3.
  • Make sure Python 3.10 or higher is installed by running python3 --version.

Why isn't pip working on my Mac?

Sign up for a free Census API key

The Census Bureau requires an API key for all data queries. The key is free with no usage charges.

  • Open your browser and navigate to the Census API key signup page:
  • https://api.census.gov/data/key_signup.html
  • Enter your organization name (or your name) in the Organization Name field.
  • Enter your email address. Use a .com, .net, .org, .gov, or .edu email address.
  • Check the I agree to the terms of service box.
  • Click Submit Key Request.
  • Check your email inbox. You will receive an email with your API key code and a link to register it.
  • Click the registration link in the email to activate your key.
  • Copy your API key and keep it somewhere safe. You will paste it into your script in the next substep.

What is an API key?

An API key is a unique string that identifies your requests to a server. The Census Bureau uses it to track usage and prevent abuse. Your key is free and has no cost associated with it.

Didn't receive the email?

  • Check your spam or junk folder.
  • Make sure you used an email with a .com, .net, .org, .gov, or .edu extension.
  • Try submitting the form again with a different email address if needed.

Help me troubleshoot my Census API key signup.

Create your project file with imports and configuration

Now that your packages are installed and your API key is ready, you will create the Python script that ties everything together.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • In Cursor, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop, select the austin-econ-tracker folder, then click Open.
  • Create a new file by pressing Cmd+N (macOS) or Ctrl+N (Windows).
  • Save it immediately with Cmd+S (macOS) or Ctrl+S (Windows) and name it austin_econ_tracker.py.

Now add the import statements at the top of your file. These bring in the libraries you installed.

  • Add the following imports to austin_econ_tracker.py:
import geopandas as gpd
import pandas as pd
import requests
import json

What do these imports do?

  • geopandas (aliased as gpd) reads shapefiles and exports GeoJSON files.
  • pandas (aliased as pd) structures tabular data into DataFrames for analysis.
  • requests sends HTTP GET requests to the Census API and returns the JSON response.
  • json is a built-in module for working with JSON data if needed.

Next, add the configuration block below the imports. This defines your API key, target ZIP codes, year range, industry sectors, and the base URL for Census API calls.

  • Add this configuration section directly below your imports in austin_econ_tracker.py:
# Configuration
API_KEY = "your-api-key-here"
ZIP_CODES = ["78741", "78703", "78702", "78721"]
YEARS = list(range(2015, 2024))
NAICS_SECTORS = {
    "44": "Retail Trade",
    "51": "Information",
    "54": "Professional/Scientific/Technical",
    "72": "Accommodation/Food Services",
    "23": "Construction",
    "62": "Health Care",
}
CBP_BASE_URL = "https://api.census.gov/data/{year}/cbp"

What does this configuration do?

  • API_KEY stores your Census API key. Replace your-api-key-here with the actual key you received by email.
  • ZIP_CODES lists the four Austin ZIP codes you will analyze: 78741, 78703, 78702, and 78721.
  • YEARS generates a list from 2015 through 2023 (9 years of economic data).
  • NAICS_SECTORS maps 2-digit NAICS industry codes to human-readable sector names. These are the industries you will track.
  • CBP_BASE_URL is the template URL for the Census County Business Patterns API. The {year} placeholder gets replaced dynamically when you query each year.
  • Replace your-api-key-here with the actual API key you received from the Census Bureau.
  • Save austin_econ_tracker.py with Cmd+S (macOS) or Ctrl+S (Windows).

Now verify that your imports work correctly by running the script.

  • Open the integrated terminal in Cursor by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Run your script by executing:
python3 austin_econ_tracker.py
  • The script should exit cleanly with no output and no errors. If you see no error messages, your imports and configuration are working.

Seeing a ModuleNotFoundError?

  • Make sure you ran pip install geopandas requests pandas in the same Python environment that python3 points to.
  • If you used pip3 to install, make sure you also run the script with python3 (not python).
  • Check that your terminal is open inside the austin-econ-tracker folder where your script lives.

Why am I getting ModuleNotFoundError even though I installed the packages?

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete austin_econ_tracker.py file at this point:

import geopandas as gpd
import pandas as pd
import requests
import json

# Configuration
API_KEY = "your-api-key-here"
ZIP_CODES = ["78741", "78703", "78702", "78721"]
YEARS = list(range(2015, 2024))
NAICS_SECTORS = {
    "44": "Retail Trade",
    "51": "Information",
    "54": "Professional/Scientific/Technical",
    "72": "Accommodation/Food Services",
    "23": "Construction",
    "62": "Health Care",
}
CBP_BASE_URL = "https://api.census.gov/data/{year}/cbp"

Your environment is ready and your script can talk to the Census Bureau. Next up, you will write the functions that pull multi-year business data from the County Business Patterns API.

Pull Multi-Year CBP Data from the Census API

Your script has its configuration ready. Now it needs the actual economic data to analyze.

The Census Bureau's County Business Patterns (CBP) API returns establishment counts, employment numbers, and payroll figures for any ZIP code and year. In this step, you'll write functions to query this API and pull the raw data your pipeline needs.

In this step, get ready to:
  • Write a function that queries the CBP API for a given year and ZIP code.
  • Test the function to confirm your API connection works.
  • Write a second function that pulls sector-level data for industry composition.
Write the CBP totals function

The CBP API returns data as a JSON array where the first element contains column headers and the second element contains the values. Your function needs to build the URL, make the request, and parse this structure into a clean Python dictionary.

  • In austin_econ_tracker.py, add the following function below the CBP_BASE_URL line:
def fetch_cbp_totals(year, zip_code):
    """Fetch total establishment, employment, and payroll for a ZIP and year."""
    url = (
        f"{CBP_BASE_URL.format(year=year)}"
        f"?get=NAME,NAICS2017_LABEL,ESTAB,EMP,PAYANN"
        f"&for=zip%20code:{zip_code}"
        f"&NAICS2017=00&LFO=001&EMPSZES=001"
        f"&key={API_KEY}"
    )
    response = requests.get(url)
    if response.status_code != 200:
        print(f"  Warning: No data for ZIP {zip_code} in {year} (HTTP {response.status_code})")
        return None
    data = response.json()
    if len(data) < 2:
        print(f"  Warning: Empty response for ZIP {zip_code} in {year}")
        return None
    headers = data[0]
    values = data[1]
    row = dict(zip(headers, values))
    return {
        "zip_code": zip_code,
        "year": year,
        "estab": int(row.get("ESTAB", 0)),
        "emp": int(row.get("EMP", 0)),
        "payann": int(row.get("PAYANN", 0)),
    }

What does this code do?

This function builds a URL that asks the Census API for total establishments, employees, and annual payroll for one ZIP code in one year.

  • NAICS2017=00 filters to "all sectors" so you get the grand total for the ZIP.
  • LFO=001 and EMPSZES=001 filter to all legal forms and all employment sizes, giving you the complete picture.
  • The error handling returns None for years where data is unavailable. This lets the loop skip gaps gracefully instead of crashing.
  • Save austin_econ_tracker.py.

Seeing a syntax error?

  • Make sure the function is indented consistently. Python requires all lines inside the function to use the same indentation level.
  • Check that there is a blank line between the CBP_BASE_URL line and the def keyword.
  • Verify the f-string quotes match. Each line of the URL uses double quotes inside the f-string.

Help me debug a syntax error in my fetch_cbp_totals function

Test the function with a single API call

Before looping over 9 years and 4 ZIP codes (36 API calls), you should verify that a single call returns the expected data structure.

  • Test the function by running this command in your terminal:
python3 -c "from austin_econ_tracker import fetch_cbp_totals; print(fetch_cbp_totals(2023, '78702'))"

You should see a dictionary with keys zip_code, year, estab, emp, and payann printed to the console, each with integer values.

What is this testing?

This single call confirms your API key works, the URL is built correctly, and the JSON parsing logic handles the Census response format. In the final script, a loop will call this function for every year-ZIP combination and collect the results into a pandas DataFrame.

Seeing None printed instead of a dictionary?

  • Check that you replaced your-api-key-here in the API_KEY variable with the actual key you received via email from the Census Bureau.
  • If you see an HTTP error code printed, your key may not have activated yet. Wait a minute and try again.
  • Make sure you are running the command from the same folder where austin_econ_tracker.py lives.

Help me debug my Census API call returning None

Write the sector data function

Total establishment counts tell you the overall trend. To understand which industries are driving that change, you also need sector-level data.

This second function queries the same CBP API but filters by 2-digit NAICS codes (like 44 for Retail Trade, 51 for Information). It pulls establishment counts per sector for a single year.

  • Add the following function below fetch_cbp_totals in austin_econ_tracker.py:
def fetch_sector_data(zip_code, year=2023):
    """Fetch 2-digit NAICS sector establishment counts for a ZIP."""
    sector_results = []
    for naics_code, sector_name in NAICS_SECTORS.items():
        url = (
            f"{CBP_BASE_URL.format(year=year)}"
            f"?get=NAICS2017_LABEL,ESTAB"
            f"&for=zip%20code:{zip_code}"
            f"&NAICS2017={naics_code}&LFO=001&EMPSZES=001"
            f"&key={API_KEY}"
        )
        response = requests.get(url)
        if response.status_code == 200:
            data = response.json()
            if len(data) >= 2:
                headers = data[0]
                values = data[1]
                row = dict(zip(headers, values))
                sector_results.append({
                    "naics_code": naics_code,
                    "sector_name": sector_name,
                    "estab": int(row.get("ESTAB", 0)),
                })
    return sector_results

What does this code do?

  • The function loops through each sector code in NAICS_SECTORS (Retail Trade, Information, Construction, etc.) and makes one API call per sector.
  • It silently skips sectors that return no data (some ZIPs may lack certain industries) by only appending results for successful responses.
  • The default year is 2023, pulling the most recent snapshot of industry composition. You'll use this later to identify which sectors are growing or declining in each ZIP.
  • Save austin_econ_tracker.py.
  • Verify the sector function works by running this in your terminal:
python3 -c "from austin_econ_tracker import fetch_sector_data; print(fetch_sector_data('78702'))"

You should see a list of dictionaries, each containing naics_code, sector_name, and estab fields.

Getting an empty list?

  • An empty list means every sector call failed. Double-check your API key is valid by re-testing fetch_cbp_totals first.
  • Make sure NAICS_SECTORS is defined at the top of your file with the six sector codes.

Help me debug my fetch_sector_data function returning an empty list

✔️ 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 austin_econ_tracker.py so far:

import geopandas as gpd
import pandas as pd
import requests
import json

# Configuration
API_KEY = "your-api-key-here"
ZIP_CODES = ["78741", "78703", "78702", "78721"]
YEARS = list(range(2015, 2024))
NAICS_SECTORS = {
    "44": "Retail Trade",
    "51": "Information",
    "54": "Professional/Scientific/Technical",
    "72": "Accommodation/Food Services",
    "23": "Construction",
    "62": "Health Care",
}
CBP_BASE_URL = "https://api.census.gov/data/{year}/cbp"


def fetch_cbp_totals(year, zip_code):
    """Fetch total establishment, employment, and payroll for a ZIP and year."""
    url = (
        f"{CBP_BASE_URL.format(year=year)}"
        f"?get=NAME,NAICS2017_LABEL,ESTAB,EMP,PAYANN"
        f"&for=zip%20code:{zip_code}"
        f"&NAICS2017=00&LFO=001&EMPSZES=001"
        f"&key={API_KEY}"
    )
    response = requests.get(url)
    if response.status_code != 200:
        print(f"  Warning: No data for ZIP {zip_code} in {year} (HTTP {response.status_code})")
        return None
    data = response.json()
    if len(data) < 2:
        print(f"  Warning: Empty response for ZIP {zip_code} in {year}")
        return None
    headers = data[0]
    values = data[1]
    row = dict(zip(headers, values))
    return {
        "zip_code": zip_code,
        "year": year,
        "estab": int(row.get("ESTAB", 0)),
        "emp": int(row.get("EMP", 0)),
        "payann": int(row.get("PAYANN", 0)),
    }


def fetch_sector_data(zip_code, year=2023):
    """Fetch 2-digit NAICS sector establishment counts for a ZIP."""
    sector_results = []
    for naics_code, sector_name in NAICS_SECTORS.items():
        url = (
            f"{CBP_BASE_URL.format(year=year)}"
            f"?get=NAICS2017_LABEL,ESTAB"
            f"&for=zip%20code:{zip_code}"
            f"&NAICS2017={naics_code}&LFO=001&EMPSZES=001"
            f"&key={API_KEY}"
        )
        response = requests.get(url)
        if response.status_code == 200:
            data = response.json()
            if len(data) >= 2:
                headers = data[0]
                values = data[1]
                row = dict(zip(headers, values))
                sector_results.append({
                    "naics_code": naics_code,
                    "sector_name": sector_name,
                    "estab": int(row.get("ESTAB", 0)),
                })
    return sector_results

Your data-fetching functions are ready. Next up, you'll download Census boundary polygons so you can pair this economic data with actual geography.

Download and Filter ZCTA Boundaries

You now have functions that pull economic data from the Census API. But numbers alone don't tell a geographic story.

To visualize which ZIP codes are growing or declining, you need polygon boundaries that outline each area on a map. The Census Bureau publishes these as TIGER/Line shapefiles. In this step, you will download the national ZCTA boundary file and filter it down to your four Austin ZIP codes.

In this step, get ready to:
  • Download the national ZCTA shapefile from Census TIGER/Line.
  • Filter the GeoDataFrame to only your four Austin ZIP codes.
  • Reproject to WGS84 (EPSG:4326) for standard lat/lng coordinates.
Write the boundary download function

The Census Bureau publishes ZIP Code Tabulation Areas (ZCTAs) as polygon shapefiles. These are approximate geographic representations of USPS ZIP code delivery areas. For your four Austin ZIPs, ZCTAs map one-to-one with the actual delivery zones.

  • In austin_econ_tracker.py, add the following function below the fetch_sector_data function:
def download_zcta_boundaries(zip_codes):
    """Download ZCTA shapefile and filter to target ZIP codes."""
    print("Downloading ZCTA boundaries from Census TIGER/Line...")
    zcta_url = "https://www2.census.gov/geo/tiger/TIGER2020/ZCTA520/tl_2020_us_zcta520.zip"
    gdf = gpd.read_file(zcta_url)
    filtered = gdf[gdf["ZCTA5CE20"].isin(zip_codes)].copy()
    filtered = filtered.to_crs(epsg=4326)
    print(f"  Found {len(filtered)} matching ZCTA boundaries")
    return filtered

What does this code do?

  • gpd.read_file(zcta_url) reads the national ZCTA shapefile directly from the Census FTP URL. GeoPandas handles downloading, unzipping, and parsing the shapefile into a GeoDataFrame automatically.
  • The ZCTA5CE20 column contains 5-digit ZCTA codes that match standard ZIP codes. The .isin(zip_codes) filter keeps only the four Austin polygons.
  • to_crs(epsg=4326) reprojects the geometry to WGS84 latitude/longitude coordinates. This ensures the output GeoJSON uses the standard coordinate system expected by mapping tools like QGIS and Mapbox.

What is a ZCTA?

A ZCTA (ZIP Code Tabulation Area) is the Census Bureau's polygon approximation of a USPS ZIP code delivery area. Unlike ZIP codes (which are line-based mail routes), ZCTAs have defined boundaries you can draw on a map.

The national ZCTA file contains every ZCTA in the United States. Since you only need four Austin polygons, the filter step is critical for keeping your output lightweight.

  • Save austin_econ_tracker.py.
  • Test the function by running this command in your terminal:
python -c "from austin_econ_tracker import download_zcta_boundaries, ZIP_CODES; download_zcta_boundaries(ZIP_CODES)"

You should see output showing the download progress, followed by a line reading Found 4 matching ZCTA boundaries.

Why does this take a while?

The national ZCTA shapefile is large because it contains polygon boundaries for every ZIP code in the United States. GeoPandas downloads the entire file, then your filter extracts just the four Austin polygons. This one-time download is the slowest part of the pipeline.

Not seeing 4 boundaries?

  • If you see a connection error, check your internet connection. The file is hosted on the Census Bureau FTP server and requires a stable download.
  • If you see 0 matching boundaries, confirm your ZIP_CODES list contains strings ("78741"), not integers. The ZCTA5CE20 column stores codes as strings.
  • If you get a ModuleNotFoundError for geopandas, make sure you installed it with pip install geopandas in your active environment.

Help me debug my ZCTA boundary download function

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

import geopandas as gpd
import pandas as pd
import requests
import json

# Configuration
API_KEY = "your-api-key-here"
ZIP_CODES = ["78741", "78703", "78702", "78721"]
YEARS = list(range(2015, 2024))
NAICS_SECTORS = {
    "44": "Retail Trade",
    "51": "Information",
    "54": "Professional/Scientific/Technical",
    "72": "Accommodation/Food Services",
    "23": "Construction",
    "62": "Health Care",
}
CBP_BASE_URL = "https://api.census.gov/data/{year}/cbp"


def fetch_cbp_totals(year, zip_code):
    """Fetch total establishment, employment, and payroll for a ZIP and year."""
    url = (
        f"{CBP_BASE_URL.format(year=year)}"
        f"?get=NAME,NAICS2017_LABEL,ESTAB,EMP,PAYANN"
        f"&for=zip%20code:{zip_code}"
        f"&NAICS2017=00&LFO=001&EMPSZES=001"
        f"&key={API_KEY}"
    )
    response = requests.get(url)
    if response.status_code != 200:
        print(f"  Warning: No data for ZIP {zip_code} in {year} (HTTP {response.status_code})")
        return None
    data = response.json()
    if len(data) < 2:
        print(f"  Warning: Empty response for ZIP {zip_code} in {year}")
        return None
    headers = data[0]
    values = data[1]
    row = dict(zip(headers, values))
    return {
        "zip_code": zip_code,
        "year": year,
        "estab": int(row.get("ESTAB", 0)),
        "emp": int(row.get("EMP", 0)),
        "payann": int(row.get("PAYANN", 0)),
    }


def fetch_sector_data(zip_code, year=2023):
    """Fetch 2-digit NAICS sector establishment counts for a ZIP."""
    sector_results = []
    for naics_code, sector_name in NAICS_SECTORS.items():
        url = (
            f"{CBP_BASE_URL.format(year=year)}"
            f"?get=NAICS2017_LABEL,ESTAB"
            f"&for=zip%20code:{zip_code}"
            f"&NAICS2017={naics_code}&LFO=001&EMPSZES=001"
            f"&key={API_KEY}"
        )
        response = requests.get(url)
        if response.status_code == 200:
            data = response.json()
            if len(data) >= 2:
                headers = data[0]
                values = data[1]
                row = dict(zip(headers, values))
                sector_results.append({
                    "naics_code": naics_code,
                    "sector_name": sector_name,
                    "estab": int(row.get("ESTAB", 0)),
                })
    return sector_results


def download_zcta_boundaries(zip_codes):
    """Download ZCTA shapefile and filter to target ZIP codes."""
    print("Downloading ZCTA boundaries from Census TIGER/Line...")
    zcta_url = "https://www2.census.gov/geo/tiger/TIGER2020/ZCTA520/tl_2020_us_zcta520.zip"
    gdf = gpd.read_file(zcta_url)
    filtered = gdf[gdf["ZCTA5CE20"].isin(zip_codes)].copy()
    filtered = filtered.to_crs(epsg=4326)
    print(f"  Found {len(filtered)} matching ZCTA boundaries")
    return filtered

Your geographic boundaries are ready. Next up, you will compute economic change metrics and merge them into these polygons to create the enriched GeoJSON output.

Compute Change Metrics and Merge with Boundaries

You now have multi-year Census data and ZCTA boundary polygons for your four Austin ZIP codes. The raw numbers alone don't tell a story. What matters for rezoning decisions is how those numbers changed over time.

In this step, you'll write functions that compute growth rates for establishments, employment, and payroll per ZIP code. You'll also identify which industry sector dominates each area so the final GeoJSON tells a complete economic story.

In this step, get ready to:
  • Compute establishment, employment, and payroll change metrics for each ZIP code.
  • Identify the top NAICS sector per ZIP from the sector data.
  • Prepare the merge strategy to join metrics with geographic boundaries.
Write the change metrics function

To measure economic change, you need to compare the earliest and latest data points for each ZIP code. The difference between those two snapshots reveals whether a neighborhood is growing or shrinking.

  • In austin_econ_tracker.py, scroll down below the download_zcta_boundaries function.
  • Add the following function that computes change metrics per ZIP code:
def compute_change_metrics(df):
    """Compute economic change metrics per ZIP code."""
    metrics = []
    for zip_code in ZIP_CODES:
        zip_data = df[df["zip_code"] == zip_code].sort_values("year")
        if len(zip_data) < 2:
            continue
        first = zip_data.iloc[0]
        last = zip_data.iloc[-1]
        estab_change = last["estab"] - first["estab"]
        estab_change_pct = (
            round((estab_change / first["estab"]) * 100, 1)
            if first["estab"] > 0
            else 0
        )
        emp_change = last["emp"] - first["emp"]
        emp_change_pct = (
            round((emp_change / first["emp"]) * 100, 1)
            if first["emp"] > 0
            else 0
        )
        payann_change_pct = (
            round(((last["payann"] - first["payann"]) / first["payann"]) * 100, 1)
            if first["payann"] > 0
            else 0
        )
        metrics.append({
            "zip_code": zip_code,
            "estab_first_year": int(first["estab"]),
            "estab_last_year": int(last["estab"]),
            "estab_change": int(estab_change),
            "estab_change_pct": estab_change_pct,
            "emp_change_pct": emp_change_pct,
            "payann_change_pct": payann_change_pct,
            "year_start": int(first["year"]),
            "year_end": int(last["year"]),
        })
    return pd.DataFrame(metrics)

What does this code do?

This function loops through each ZIP code in your ZIP_CODES list and sorts the data chronologically. It then grabs the first and last available years as comparison points.

  • estab_change_pct calculates the percentage growth in total business establishments.
  • emp_change_pct measures whether total employment grew or shrank.
  • payann_change_pct tracks annual payroll shifts, reflecting wage growth or decline in the area.
  • The if first["estab"] > 0 guards prevent division-by-zero errors when a ZIP has no data for its earliest year.
  • Save austin_econ_tracker.py.
  • You won't see runtime output yet. The next substep adds the sector identification function, and then you'll wire both into the pipeline.

Seeing a syntax error on save?

  • Check that the function is indented consistently. Each line inside the function body needs exactly 4 spaces.
  • Make sure you have a blank line between the download_zcta_boundaries function's last line and the new def compute_change_metrics line.
  • Verify that the closing parentheses in the round() calls match up. Each ternary expression uses a multi-line format.

Help me fix a syntax error in my compute_change_metrics function

Add the sector identification function

Knowing that establishments grew by 30% is useful. Knowing they grew because of a tech sector boom is actionable. This function identifies which NAICS industry dominates each ZIP code.

  • Below compute_change_metrics, add this function that identifies the top sector for a given ZIP code:
def identify_top_sectors(zip_code):
    """Identify top growing sector for a ZIP based on establishment count."""
    sectors = fetch_sector_data(zip_code)
    if not sectors:
        return "Unknown", 0
    sorted_sectors = sorted(sectors, key=lambda x: x["estab"], reverse=True)
    top = sorted_sectors[0]
    return top["sector_name"], top["estab"]

What does this code do?

This function calls fetch_sector_data (which you wrote in the previous step) to get establishment counts per 2-digit NAICS sector. It then sorts sectors by establishment count in descending order and returns the top one.

The function returns two values: the sector name (like "Information" or "Retail Trade") and the number of establishments. If the API returns no sector data for a ZIP, it falls back to "Unknown".

  • Save austin_econ_tracker.py.
  • You won't see output yet. The next step wires both functions into the main pipeline and produces the final GeoJSON.

Seeing a NameError for fetch_sector_data?

  • Make sure identify_top_sectors is defined BELOW fetch_sector_data in the file. Python reads top to bottom, so the function must already exist before it's called.
  • Check that you didn't accidentally indent identify_top_sectors inside another function. It should be at the top level (no indentation on the def line).

Help me debug a NameError in my identify_top_sectors function

Understand the merge strategy

Your two new functions produce a metrics DataFrame with one row per ZIP code. The ZCTA GeoDataFrame has one polygon per ZIP code. To create a GeoJSON with both geometry AND metrics, you need to join these two datasets on their shared column: the ZIP code.

How does the merge work?

The ZCTA shapefile stores ZIP codes in a column called ZCTA5CE20. Your metrics DataFrame stores them in a column called zip_code. A pandas merge on these two columns will attach every metric (establishment change, employment shift, top sector) directly to the geographic polygon.

The result is a single GeoDataFrame where each row has both a polygon shape and all your computed economic data. When you export this as GeoJSON, every feature carries its metrics as properties. Any GIS tool can then style or filter the map by those properties.

You'll execute this merge in the next step when you build the main() function that orchestrates the full pipeline.

✔️ 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's the full austin_econ_tracker.py file as it should look at this point:

import geopandas as gpd
import pandas as pd
import requests
import json

# Configuration
API_KEY = "your-api-key-here"
ZIP_CODES = ["78741", "78703", "78702", "78721"]
YEARS = list(range(2015, 2024))
NAICS_SECTORS = {
    "44": "Retail Trade",
    "51": "Information",
    "54": "Professional/Scientific/Technical",
    "72": "Accommodation/Food Services",
    "23": "Construction",
    "62": "Health Care",
}
CBP_BASE_URL = "https://api.census.gov/data/{year}/cbp"


def fetch_cbp_totals(year, zip_code):
    """Fetch total establishment, employment, and payroll for a ZIP and year."""
    url = (
        f"{CBP_BASE_URL.format(year=year)}"
        f"?get=NAME,NAICS2017_LABEL,ESTAB,EMP,PAYANN"
        f"&for=zip%20code:{zip_code}"
        f"&NAICS2017=00&LFO=001&EMPSZES=001"
        f"&key={API_KEY}"
    )
    response = requests.get(url)
    if response.status_code != 200:
        print(f"  Warning: No data for ZIP {zip_code} in {year} (HTTP {response.status_code})")
        return None
    data = response.json()
    if len(data) < 2:
        print(f"  Warning: Empty response for ZIP {zip_code} in {year}")
        return None
    headers = data[0]
    values = data[1]
    row = dict(zip(headers, values))
    return {
        "zip_code": zip_code,
        "year": year,
        "estab": int(row.get("ESTAB", 0)),
        "emp": int(row.get("EMP", 0)),
        "payann": int(row.get("PAYANN", 0)),
    }


def fetch_sector_data(zip_code, year=2023):
    """Fetch 2-digit NAICS sector establishment counts for a ZIP."""
    sector_results = []
    for naics_code, sector_name in NAICS_SECTORS.items():
        url = (
            f"{CBP_BASE_URL.format(year=year)}"
            f"?get=NAICS2017_LABEL,ESTAB"
            f"&for=zip%20code:{zip_code}"
            f"&NAICS2017={naics_code}&LFO=001&EMPSZES=001"
            f"&key={API_KEY}"
        )
        response = requests.get(url)
        if response.status_code == 200:
            data = response.json()
            if len(data) >= 2:
                headers = data[0]
                values = data[1]
                row = dict(zip(headers, values))
                sector_results.append({
                    "naics_code": naics_code,
                    "sector_name": sector_name,
                    "estab": int(row.get("ESTAB", 0)),
                })
    return sector_results


def download_zcta_boundaries(zip_codes):
    """Download ZCTA shapefile and filter to target ZIP codes."""
    print("Downloading ZCTA boundaries from Census TIGER/Line...")
    zcta_url = "https://www2.census.gov/geo/tiger/TIGER2020/ZCTA520/tl_2020_us_zcta520.zip"
    gdf = gpd.read_file(zcta_url)
    filtered = gdf[gdf["ZCTA5CE20"].isin(zip_codes)].copy()
    filtered = filtered.to_crs(epsg=4326)
    print(f"  Found {len(filtered)} matching ZCTA boundaries")
    return filtered


def compute_change_metrics(df):
    """Compute economic change metrics per ZIP code."""
    metrics = []
    for zip_code in ZIP_CODES:
        zip_data = df[df["zip_code"] == zip_code].sort_values("year")
        if len(zip_data) < 2:
            continue
        first = zip_data.iloc[0]
        last = zip_data.iloc[-1]
        estab_change = last["estab"] - first["estab"]
        estab_change_pct = (
            round((estab_change / first["estab"]) * 100, 1)
            if first["estab"] > 0
            else 0
        )
        emp_change = last["emp"] - first["emp"]
        emp_change_pct = (
            round((emp_change / first["emp"]) * 100, 1)
            if first["emp"] > 0
            else 0
        )
        payann_change_pct = (
            round(((last["payann"] - first["payann"]) / first["payann"]) * 100, 1)
            if first["payann"] > 0
            else 0
        )
        metrics.append({
            "zip_code": zip_code,
            "estab_first_year": int(first["estab"]),
            "estab_last_year": int(last["estab"]),
            "estab_change": int(estab_change),
            "estab_change_pct": estab_change_pct,
            "emp_change_pct": emp_change_pct,
            "payann_change_pct": payann_change_pct,
            "year_start": int(first["year"]),
            "year_end": int(last["year"]),
        })
    return pd.DataFrame(metrics)


def identify_top_sectors(zip_code):
    """Identify top growing sector for a ZIP based on establishment count."""
    sectors = fetch_sector_data(zip_code)
    if not sectors:
        return "Unknown", 0
    sorted_sectors = sorted(sectors, key=lambda x: x["estab"], reverse=True)
    top = sorted_sectors[0]
    return top["sector_name"], top["estab"]

Your analysis functions are ready. Next up, you'll build the main() function that orchestrates the full pipeline: fetching data, computing metrics, merging everything with boundaries, and exporting the final GeoJSON.

Export GeoJSON and Print Summary

Your enriched GeoDataFrame now holds everything: ZIP polygons merged with establishment growth, employment shifts, payroll trends, and top sectors.

The final step is getting this data out of Python and into a format that mapping tools can read. GeoJSON is the standard interchange format for geographic features, supported by QGIS, Mapbox, kepler.gl, and every major GIS tool.

In this step, get ready to:
  • Export the merged GeoDataFrame to a GeoJSON file.
  • Print a console summary interpreting economic change per ZIP.
  • Validate the output by reloading the file and confirming its structure.
Export the GeoDataFrame to GeoJSON

GeoPandas makes exporting spatial data straightforward. The to_file method writes your GeoDataFrame directly to a GeoJSON file that any mapping tool can open.

  • In austin_econ_tracker.py, find the comment # Step 5: Merge metrics with geometry and the merge logic below it.
  • Add the following code after the merge block to export the GeoJSON:
    # Step 6: Export GeoJSON
    output_file = "austin_economic_change.geojson"
    gdf.to_file(output_file, driver="GeoJSON")
    print(f"\n  Exported: {output_file}")

What does this code do?

  • output_file stores the filename so you can reference it later for validation.
  • gdf.to_file(output_file, driver="GeoJSON") writes the entire GeoDataFrame (geometry + properties) to a single .geojson file.
  • The driver="GeoJSON" parameter tells GeoPandas to use the GeoJSON format instead of the default Shapefile.
  • Save austin_econ_tracker.py.

You won't see a change yet. The next chunk adds the summary output you'll see when you run the script.

Print a console summary for each ZIP

A GeoJSON file is great for mapping tools, but you also want a human-readable summary right in the terminal. This loop prints key metrics per ZIP so you can quickly interpret economic trends without opening a separate application.

  • Add the following code below the export block you just wrote:
    # Step 7: Print summary
    print("\n" + "=" * 60)
    print("RESULTS SUMMARY")
    print("=" * 60)
    for _, row in gdf.iterrows():
        zip_code = row["ZCTA5CE20"]
        print(f"\n  ZIP {zip_code}:")
        print(f"    Establishments: {row['estab_first_year']} -> {row['estab_last_year']} ({row['estab_change_pct']:+.1f}%)")
        print(f"    Employment change: {row['emp_change_pct']:+.1f}%")
        print(f"    Payroll change: {row['payann_change_pct']:+.1f}%")
        print(f"    Top sector: {row['top_sector']}")

What does this code do?

  • gdf.iterrows() loops through each row (one per ZIP code) in the merged GeoDataFrame.
  • The :+.1f format specifier shows numbers with a plus/minus sign and one decimal place, making growth and decline immediately visible.
  • Each ZIP's summary shows establishment count change over time, employment change percentage, payroll change percentage, and the top NAICS sector by establishment count.
  • Save austin_econ_tracker.py.

You won't see output yet. The validation chunk below adds the final piece and runs the script.

Validate the output and run the script

A quick validation step reloads the exported file and confirms it has the expected structure. This catches export issues before you hand the file to a mapping tool.

  • Add the following validation code after the summary block, plus the entry point that runs main():
    # Step 8: Validate output
    print("\n--- Validating output ---")
    validation_gdf = gpd.read_file(output_file)
    print(f"  Features: {len(validation_gdf)}")
    print(f"  Properties: {list(validation_gdf.columns)}")
    print("\nDone!")


if __name__ == "__main__":
    main()

What does this code do?

  • gpd.read_file(output_file) reloads the GeoJSON you just wrote, proving the file is valid and readable.
  • Printing the feature count and column list gives you a quick sanity check. You expect 4 features (one per ZIP) and properties like estab_change_pct and top_sector.
  • if __name__ == "__main__": ensures main() only runs when you execute the file directly, not when importing it as a module.
  • Save austin_econ_tracker.py.
  • Run the script in your terminal by running this command:
python austin_econ_tracker.py
  • You should see output showing each year's data being fetched, a download message for ZCTA boundaries, sector composition per ZIP, a RESULTS SUMMARY section with metrics for all 4 ZIP codes, and a final validation confirming 4 features.

Why does the ZCTA download take a while?

The national ZCTA shapefile is large (around 800MB unzipped). GeoPandas downloads and reads it directly from the Census URL, which can take a few minutes depending on your internet connection.

Script not producing output?

  • Make sure your API_KEY variable at the top of the file contains the key you received from the Census Bureau.
  • Check that you are running the command from the same directory as austin_econ_tracker.py.
  • If you see HTTP errors for certain years, that is expected. Some year/ZIP combinations may not have data. The script prints warnings and continues.

Why isn't my Austin economic tracker script producing any output?

  • Confirm that austin_economic_change.geojson now exists in your project folder by running:
ls
  • You should see austin_economic_change.geojson listed alongside austin_econ_tracker.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and that the GeoJSON was created successfully.

ⓧ I'd like to double check the full code

Here is the complete austin_econ_tracker.py file:

import geopandas as gpd
import pandas as pd
import requests
import json

# Configuration
API_KEY = "your-api-key-here"
ZIP_CODES = ["78741", "78703", "78702", "78721"]
YEARS = list(range(2015, 2024))
NAICS_SECTORS = {
    "44": "Retail Trade",
    "51": "Information",
    "54": "Professional/Scientific/Technical",
    "72": "Accommodation/Food Services",
    "23": "Construction",
    "62": "Health Care",
}
CBP_BASE_URL = "https://api.census.gov/data/{year}/cbp"


def fetch_cbp_totals(year, zip_code):
    """Fetch total establishment, employment, and payroll for a ZIP and year."""
    url = (
        f"{CBP_BASE_URL.format(year=year)}"
        f"?get=NAME,NAICS2017_LABEL,ESTAB,EMP,PAYANN"
        f"&for=zip%20code:{zip_code}"
        f"&NAICS2017=00&LFO=001&EMPSZES=001"
        f"&key={API_KEY}"
    )
    response = requests.get(url)
    if response.status_code != 200:
        print(f"  Warning: No data for ZIP {zip_code} in {year} (HTTP {response.status_code})")
        return None
    data = response.json()
    if len(data) < 2:
        print(f"  Warning: Empty response for ZIP {zip_code} in {year}")
        return None
    headers = data[0]
    values = data[1]
    row = dict(zip(headers, values))
    return {
        "zip_code": zip_code,
        "year": year,
        "estab": int(row.get("ESTAB", 0)),
        "emp": int(row.get("EMP", 0)),
        "payann": int(row.get("PAYANN", 0)),
    }


def fetch_sector_data(zip_code, year=2023):
    """Fetch 2-digit NAICS sector establishment counts for a ZIP."""
    sector_results = []
    for naics_code, sector_name in NAICS_SECTORS.items():
        url = (
            f"{CBP_BASE_URL.format(year=year)}"
            f"?get=NAICS2017_LABEL,ESTAB"
            f"&for=zip%20code:{zip_code}"
            f"&NAICS2017={naics_code}&LFO=001&EMPSZES=001"
            f"&key={API_KEY}"
        )
        response = requests.get(url)
        if response.status_code == 200:
            data = response.json()
            if len(data) >= 2:
                headers = data[0]
                values = data[1]
                row = dict(zip(headers, values))
                sector_results.append({
                    "naics_code": naics_code,
                    "sector_name": sector_name,
                    "estab": int(row.get("ESTAB", 0)),
                })
    return sector_results


def download_zcta_boundaries(zip_codes):
    """Download ZCTA shapefile and filter to target ZIP codes."""
    print("Downloading ZCTA boundaries from Census TIGER/Line...")
    zcta_url = "https://www2.census.gov/geo/tiger/TIGER2020/ZCTA520/tl_2020_us_zcta520.zip"
    gdf = gpd.read_file(zcta_url)
    filtered = gdf[gdf["ZCTA5CE20"].isin(zip_codes)].copy()
    filtered = filtered.to_crs(epsg=4326)
    print(f"  Found {len(filtered)} matching ZCTA boundaries")
    return filtered


def compute_change_metrics(df):
    """Compute economic change metrics per ZIP code."""
    metrics = []
    for zip_code in ZIP_CODES:
        zip_data = df[df["zip_code"] == zip_code].sort_values("year")
        if len(zip_data) < 2:
            continue
        first = zip_data.iloc[0]
        last = zip_data.iloc[-1]
        estab_change = last["estab"] - first["estab"]
        estab_change_pct = (
            round((estab_change / first["estab"]) * 100, 1)
            if first["estab"] > 0
            else 0
        )
        emp_change = last["emp"] - first["emp"]
        emp_change_pct = (
            round((emp_change / first["emp"]) * 100, 1)
            if first["emp"] > 0
            else 0
        )
        payann_change_pct = (
            round(((last["payann"] - first["payann"]) / first["payann"]) * 100, 1)
            if first["payann"] > 0
            else 0
        )
        metrics.append({
            "zip_code": zip_code,
            "estab_first_year": int(first["estab"]),
            "estab_last_year": int(last["estab"]),
            "estab_change": int(estab_change),
            "estab_change_pct": estab_change_pct,
            "emp_change_pct": emp_change_pct,
            "payann_change_pct": payann_change_pct,
            "year_start": int(first["year"]),
            "year_end": int(last["year"]),
        })
    return pd.DataFrame(metrics)


def identify_top_sectors(zip_code):
    """Identify top growing sector for a ZIP based on establishment count."""
    sectors = fetch_sector_data(zip_code)
    if not sectors:
        return "Unknown", 0
    sorted_sectors = sorted(sectors, key=lambda x: x["estab"], reverse=True)
    top = sorted_sectors[0]
    return top["sector_name"], top["estab"]


def main():
    print("=" * 60)
    print("Austin Economic Change Tracker")
    print("=" * 60)
    print(f"\nTarget ZIPs: {', '.join(ZIP_CODES)}")
    print(f"Year range: {YEARS[0]}-{YEARS[-1]}")

    # Step 1: Fetch multi-year CBP data
    print("\n--- Fetching Census CBP data ---")
    all_rows = []
    for year in YEARS:
        print(f"  Year {year}...")
        for zip_code in ZIP_CODES:
            row = fetch_cbp_totals(year, zip_code)
            if row:
                all_rows.append(row)
    df = pd.DataFrame(all_rows)
    print(f"\n  Collected {len(df)} data points")

    # Step 2: Download ZCTA boundaries
    print("\n--- Downloading ZCTA boundaries ---")
    gdf = download_zcta_boundaries(ZIP_CODES)

    # Step 3: Compute change metrics
    print("\n--- Computing change metrics ---")
    metrics_df = compute_change_metrics(df)

    # Step 4: Get sector composition
    print("\n--- Fetching sector composition ---")
    top_sectors = {}
    for zip_code in ZIP_CODES:
        sector_name, sector_estab = identify_top_sectors(zip_code)
        top_sectors[zip_code] = sector_name
        print(f"  {zip_code}: top sector = {sector_name} ({sector_estab} establishments)")
    metrics_df["top_sector"] = metrics_df["zip_code"].map(top_sectors)

    # Step 5: Merge metrics with geometry
    print("\n--- Merging data with boundaries ---")
    gdf = gdf.merge(metrics_df, left_on="ZCTA5CE20", right_on="zip_code", how="inner")

    # Step 6: Export GeoJSON
    output_file = "austin_economic_change.geojson"
    gdf.to_file(output_file, driver="GeoJSON")
    print(f"\n  Exported: {output_file}")

    # Step 7: Print summary
    print("\n" + "=" * 60)
    print("RESULTS SUMMARY")
    print("=" * 60)
    for _, row in gdf.iterrows():
        zip_code = row["ZCTA5CE20"]
        print(f"\n  ZIP {zip_code}:")
        print(f"    Establishments: {row['estab_first_year']} -> {row['estab_last_year']} ({row['estab_change_pct']:+.1f}%)")
        print(f"    Employment change: {row['emp_change_pct']:+.1f}%")
        print(f"    Payroll change: {row['payann_change_pct']:+.1f}%")
        print(f"    Top sector: {row['top_sector']}")

    # Step 8: Validate output
    print("\n--- Validating output ---")
    validation_gdf = gpd.read_file(output_file)
    print(f"  Features: {len(validation_gdf)}")
    print(f"  Properties: {list(validation_gdf.columns)}")
    print("\nDone!")


if __name__ == "__main__":
    main()

Your data pipeline is complete. You now have a GeoJSON file with four Austin ZIP code polygons enriched with years of economic change data, ready to load into any mapping tool for rezoning analysis.

Secret mission

Add Trend Classification Scoring

Your current script compares the first year to the last year. But what happened in between? A ZIP might show +20% overall growth while actually being wildly unstable year to year. Add year-over-year trend scoring to classify each ZIP as accelerating, stable, declining, or volatile.

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:

  • Local project files (austin_econ_tracker.py and austin_economic_change.geojson).
  • Installed Python packages (geopandas, requests, pandas, numpy).
  • Census API key (free, no cost to maintain).

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the pipeline with new ZIP codes or year ranges.

  • Your austin_econ_tracker.py script and austin_economic_change.geojson output stay on your Desktop.
  • Your Census API key remains active and free with no usage limits.
  • The installed Python packages remain available for other projects.

Pause - I'll come back to this later

There are no running processes or cloud resources to pause. Your files and packages sit idle on disk with zero cost.

  • Leave everything as-is. Nothing consumes CPU, memory, or network while the script is not running.
  • When you're ready to return, open austin_econ_tracker.py in Cursor and run it again.

Delete - I don't want to use this again

Remove all project files and uninstall the Python packages you added.

  • Delete the project files by running:
cd ~/Desktop
rm austin_econ_tracker.py austin_economic_change.geojson
  • Uninstall the Python packages by running:
pip uninstall geopandas requests pandas numpy -y
  • Your Census API key is free and has no associated cost. You can leave it active or disregard it.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete data pipeline that pulls real economic data from a federal API, merges it with geographic boundaries, and outputs analysis-ready GeoJSON for city planning decisions.

You've learned how to:

  • Query the Census Bureau County Business Patterns API across multiple years and ZIP codes to collect establishment, employment, and payroll data.
  • Download Census TIGER/Line ZCTA boundary polygons and merge them with computed economic change metrics using GeoPandas.
  • Export an enriched GeoJSON file combining geographic shapes with time-series economic analysis, ready for tools like QGIS or kepler.gl.
  • Secret Mission: Added year-over-year trend classification scoring to label each ZIP's economic trajectory as accelerating, stable, declining, or volatile.

Ready to quiz yourself?