Back
Guides›Brand Comparison›Measuring how far visitors travel to Buc-ee's, Costco, and three other chains
USA

Measuring how far visitors travel to Buc-ee's, Costco, and three other chains

This example shows how to measure how far a chain's visitors travel, from ZIP-level home trade areas: for every store, the share of its visitors whose home ZIP lies more than 25, 50, and 100 miles away, and then the chain figure as the average over its stores. We run it for five chains that span the range, Pilot Travel Center, Buc-ee's, Cracker Barrel, Costco, and Macy's.

For this we use our quarterly ZIP trade areas, fetched from Python and worked in pandas. Every trade-area row carries the store's brand and coordinates, so there is no join against a places directory. The helper pages through results, because a national chain's trade areas run to tens of thousands of rows:

import os
import requests
import numpy as np
import pandas as pd

API = "https://api.pine59.com/v3/datasets"
HEADERS = {"Authorization": f"Bearer {os.environ['PINE59_API_KEY']}"}

def run_query(dataset, body):
    rows, token = [], ""
    while True:
        r = requests.post(f"{API}/{dataset}:runQuery", json={**body, "pageToken": token}, headers=HEADERS)
        r.raise_for_status()
        payload = r.json()
        rows += [rec["fields"] for rec in payload.get("records", [])]
        token = payload.get("nextPageToken", "")
        if not token:
            return rows

BRANDS  = ["Pilot Travel Center", "Buc-ee's", "Cracker Barrel", "Costco", "Macy's"]
QUARTER = "2026-04-01"   # trade areas are quarterly; Q2 2026

1. Home trade areas by ZIP

The trade area lists, for each store, the home ZIP codes its visitors come from and the fraction of visitors each ZIP contributes (trade_area_zip, one row per store and ZIP, quarterly). We take the HOME type for one quarter, all five brands in one request, and ask for the store's coordinates alongside:

trade_areas = pd.DataFrame(run_query("visitation_usa_places.trade_area_zip", {
    "fields": [
        {"name": "location_id"},
        {"name": "brands"},
        {"name": "latitude"},
        {"name": "longitude"},
        {"name": "trade_area_location_id"},
        {"name": "people_fraction"},
    ],
    "filters": [
        {"fieldName": "brands",          "operator": "in", "values": BRANDS},
        {"fieldName": "trade_area_type", "operator": "==", "value": "HOME"},
        {"fieldName": "observation_start_date", "operator": "==", "value": QUARTER},
    ],
    "pageSize": 10000,
})).astype({"latitude": float, "longitude": float, "people_fraction": float})

print(len(trade_areas), "store-to-ZIP rows for", trade_areas.location_id.nunique(), "stores")
print(trade_areas.groupby("brands").location_id.nunique())

Q2 2026: 112,311 store-to-ZIP rows for 1,996 stores (378 Pilot, 54 Buc-ee's, 609 Cracker Barrel, 572 Costco, 383 Macy's), between 45 and 75 home ZIPs per store on average. Rows come back only for stores that existed at the start of the quarter, so a location that closed before April 2026, or opened after it, contributes nothing. A misspelled brand returns zero rows rather than an error; brands values can be confirmed with :searchFieldValues first, as the grocery brand comparison does. The fractions for a store add up to less than one, because ZIPs that contribute below a reporting floor are left out; step 2 handles that.

2. Distance from store to home ZIP

A ZIP code is an area, so we measure to its center: the internal point the Census Bureau publishes for every ZIP Code Tabulation Area, read straight from the 2020 Gazetteer file. The distance is the straight line from the store to that point, in miles. Not every ZIP has a ZCTA (post-office-box and single-building ZIPs do not, nor do ZIPs created after 2020), so we print what the match loses. Per store, the three shares are the fractions summed over ZIPs beyond each threshold, divided by the fraction the matched trade area covers:

zips = pd.read_csv(
    "https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2020_Gazetteer/2020_Gaz_zcta_national.zip",
    sep="\t", dtype={"GEOID": str},
)
zips.columns = zips.columns.str.strip()
zips = zips.rename(columns={"GEOID": "trade_area_location_id", "INTPTLAT": "zip_lat", "INTPTLONG": "zip_lon"})

rows = trade_areas.merge(zips[["trade_area_location_id", "zip_lat", "zip_lon"]], on="trade_area_location_id")
lost = 1 - rows.people_fraction.sum() / trade_areas.people_fraction.sum()
print(f"{lost:.1%} of the visitor fractions sit in ZIPs without a ZCTA and are dropped")

def miles_between(lat1, lon1, lat2, lon2):
    lat1, lon1, lat2, lon2 = map(np.radians, (lat1, lon1, lat2, lon2))
    a = np.sin((lat2 - lat1) / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2
    return 3958.8 * 2 * np.arcsin(np.sqrt(a))

rows["miles"] = miles_between(rows.latitude, rows.longitude, rows.zip_lat, rows.zip_lon)

def store_profile(g):
    covered = g.people_fraction.sum()
    return pd.Series({
        "covered":    covered,
        "over_25":    g.people_fraction[g.miles > 25].sum() / covered,
        "over_50":    g.people_fraction[g.miles > 50].sum() / covered,
        "over_100":   g.people_fraction[g.miles > 100].sum() / covered,
        "mean_miles": (g.people_fraction * g.miles).sum() / covered,
    })

per_store = (rows.groupby(["brands", "location_id"])[["people_fraction", "miles"]]
    .apply(store_profile).reset_index())

print(len(per_store), "stores")
print(per_store.sort_values("over_50", ascending=False).head(5).round(3).to_string(index=False))

Q2 2026: 0.3% of the visitor fractions fall in ZIPs without a ZCTA, and 1,996 stores get a distance profile. The top of the list is interstate truck stops with practically every home ZIP more than 50 miles away, and a couple of ordinary Costco warehouses appear among them: single-store figures are worth a look before they are trusted, which is one reason the chain figure in step 3 pools hundreds of stores.

How to read it: covered is the share of a store's visitors that the matched trade area accounts for, after both the reporting floor and the ZCTA match, and the three shares are computed within it. A store with low coverage has many small home ZIPs below the floor, which are disproportionately far away, so its shares are less reliable than a store with high coverage. Dividing by covered rather than by one keeps stores comparable; the next step drops the ones where too much is missing.

3. Chain figures

The chain figure is the average of its stores' shares, so every store counts once whatever its size. A mean is the right pool here because a share is bounded between zero and one; a store's mean distance is unbounded, so for the typical store distance we take the median of the store means instead, as the Brand Comparison guide recommends for store distributions. Two guardrails, both parameters: a store enters only if its trade area covers at least 75% of its visitors, and a chain is reported only with at least 20 such stores:

MIN_COVERAGE = 0.75
MIN_STORES   = 20

kept = per_store[per_store.covered >= MIN_COVERAGE]
chains = (kept.groupby("brands")
    .agg(stores=("location_id", "size"),
         over_25=("over_25", "mean"),
         over_50=("over_50", "mean"),
         over_100=("over_100", "mean"),
         typical_store_miles=("mean_miles", "median"))
    .join(per_store.groupby("brands").size().rename("all_stores")))
chains = chains[chains.stores >= MIN_STORES].sort_values("over_50", ascending=False)

print(chains.round(3).to_string())

Result, Q2 2026 (Apr–Jun), share of visitors at the average store whose home ZIP is beyond each distance:

ChainStores keptOver 25 miOver 50 miOver 100 miTypical store, miles (median of store means)
Pilot Travel Center140 of 37876%68%58%411
Buc-ee's37 of 5453%34%20%83
Cracker Barrel577 of 60933%25%20%92
Costco569 of 57212%8%6%32
Macy's369 of 38311%6%4%26

How to read it: the shares describe where visitors live, not how they got there. Two thirds of a Pilot store's visitors live more than 50 miles away, which is what an interstate stop looks like: most of its visitors are passing through. Buc-ee's and Cracker Barrel sit in between, one visitor in three or four from beyond 50 miles, and Costco and Macy's draw almost entirely from the surrounding area. The coverage guardrail removes most of Pilot's stores, because a truck stop's visitors come from thousands of small ZIP contributions that fall below the floor, and the same effect makes far-away shares a lower bound for every chain.

Every number here is a choice to adapt: the distance thresholds, the coverage floor, the minimum store count, and straight-line distance rather than driving distance. Compare chains on the same quarter, because trade areas move with the season; a chain's figure for a summer quarter is not the same as for a winter one.

Scaling this. Five chains fit in a dozen pages of 10,000 rows. A chain with thousands of stores, or a set of fifty chains, runs to hundreds of thousands of trade-area rows; for that, run the same request shape through the export API, which executes server-side and returns files that pandas reads directly.

Did you find what you were looking for?