Profiling an H-E-B store in San Antonio
This example shows how to build a store profile for one location, an H-E-B in San Antonio's Medical Center, and why its numbers only make sense once you know its market is the workday, not the neighborhood.
For this we use our US Places metrics, queried from Python. The helper below is all the setup the example needs:
import os
import requests
API = "https://api.pine59.com/v3/datasets"
HEADERS = {"Authorization": f"Bearer {os.environ['PINE59_API_KEY']}"}
def run_query(dataset, body):
r = requests.post(f"{API}/{dataset}:runQuery", json=body, headers=HEADERS)
r.raise_for_status()
return [rec["fields"] for rec in r.json().get("records", [])]
1. Find the location
The first step is to find the location you want to profile. In this example we use the Places records dataset with filters to find the right location.
The location_id field holds the Placekey value for this dataset, which uniquely identifies a location. This is the value we use in all further steps to filter the data queries.
The specific location we are looking for is the H-E-B at 8300 Floyd Curl Dr in the South Texas Medical Center. For simplicity we run a query which lists all the H-E-B locations in San Antonio and pick ours from the list:
stores = run_query("visitation_usa_places.places", {
"fields": [
{"name": "location_id"},
{"name": "street_address"},
{"name": "postal_code"},
],
"filters": [
{"fieldName": "brands", "operator": "==", "value": "H-E-B"},
{"fieldName": "city", "operator": "==", "value": "San Antonio"},
],
"pageSize": 100,
})
for s in sorted(stores, key=lambda s: s["street_address"]):
print(s["location_id"], s["street_address"], s["postal_code"])
The query lists every H-E-B in the city. Ours is 222-222@8sz-tfd-789 at 8300 Floyd Curl Dr Ste 105, ZIP 78229:
STORE = "222-222@8sz-tfd-789"
The Places records also hold other metadata such as brand, category, coordinates, the census block group, and the parent placekey of the center a store sits in.
2. Foot traffic trends
Now we have the location id and can pull data for the foot traffic profile. We look at it from two angles: the store's own monthly trend with the years laid over each other, and the same trend set against comparison groups.
Monthly trend, years overlaid
foot_traffic_month gives one row per location per month. Pivoting the months into one column per year puts the seasonal shape and the year-on-year change in the same view:
import pandas as pd
rows = run_query("visitation_usa_places.foot_traffic_month", {
"fields": [{"name": "observation_start_date"}, {"name": "visits_sum"}],
"filters": [
{"fieldName": "location_id", "operator": "==", "value": STORE},
{"fieldName": "observation_start_date", "operator": ">=", "value": "2024-01-01"},
{"fieldName": "observation_start_date", "operator": "<", "value": "2026-01-01"},
],
"pageSize": 100,
})
df = pd.DataFrame(rows)
df["month"] = pd.to_datetime(df["observation_start_date"])
df["visits"] = df["visits_sum"].astype(float)
overlay = df.pivot_table(index=df["month"].dt.month,
columns=df["month"].dt.year, values="visits")
print((overlay / 1000).round().astype(int))
overlay.plot() # two lines, Jan through Dec
Output (visits in thousands):
| Month | 2024 | 2025 |
|---|---|---|
| Jan | 97 | 99 |
| Feb | 87 | 88 |
| Mar | 90 | 96 |
| Apr | 90 | 95 |
| May | 96 | 96 |
| Jun | 79 | 91 |
| Jul | 73 | 94 |
| Aug | 77 | 95 |
| Sep | 73 | 98 |
| Oct | 104 | 104 |
| Nov | 92 | 94 |
| Dec | 93 | 98 |
How to read it: 2025 runs at or above 2024 in every month, and the deep 2024 summer trough (June to September) did not repeat: 2025 holds in the 90s all year. On the full year the store grew 9%. Whether that is the store or the market is exactly what the next view answers.
Comparative trend
A store's own trend needs a reference before it means anything. We build three from the same query with different filters, plus the store itself, and index every series to its own 2024 average so they plot on one scale:
series_filters = {
"This store": [
{"fieldName": "location_id", "operator": "==", "value": STORE}],
"H-E-B San Antonio": [
{"fieldName": "brands", "operator": "==", "value": "H-E-B"},
{"fieldName": "city", "operator": "==", "value": "San Antonio"},
# the store that opened in Aug 2025; excluded for a same-store series
{"fieldName": "location_id", "operator": "!=", "value": "zzy-223@8sz-tk6-p5f"}],
"Competitor set": [
{"fieldName": "brands", "operator": "in", "values": [
"Walmart", "Costco", "Sprouts Farmers Market",
"Trader Joe's", "Whole Foods Market"]},
{"fieldName": "city", "operator": "==", "value": "San Antonio"}],
"Other supermarkets": [
{"fieldName": "sub_category", "operator": "==",
"value": "Supermarkets and Other Grocery (except Convenience) Stores"},
{"fieldName": "city", "operator": "==", "value": "San Antonio"},
{"fieldName": "brands", "operator": "!=", "value": "H-E-B"}],
}
window = [
{"fieldName": "observation_start_date", "operator": ">=", "value": "2024-01-01"},
{"fieldName": "observation_start_date", "operator": "<", "value": "2026-01-01"},
]
indexed = {}
for name, filters in series_filters.items():
rows = run_query("visitation_usa_places.foot_traffic_month", {
"fields": [
{"name": "observation_start_date"},
{"name": "visits_sum", "aggregation": "SUM"},
{"name": "location_id", "aggregation": "COUNT_DISTINCT"},
],
"filters": filters + window,
"groupBy": [{"fieldName": "observation_start_date"}],
"pageSize": 100,
})
s = pd.Series({r["observation_start_date"]: float(r["visits_sum_SUM"])
for r in rows}).sort_index()
indexed[name] = s / s[:"2024-12-01"].mean() * 100
pd.DataFrame(indexed).plot() # four lines, common dips are the market
The COUNT_DISTINCT column is the discipline that keeps a fleet series honest. Run without the last filter, it shows the San Antonio H-E-B fleet going from 45 to 46 stores in August 2025; the != filter on that store's location_id turns the chain series into a same-store series, which is the one the table uses. Quarterly averages, indexed to each series' 2024 average = 100:
| Series | Q1 24 | Q2 24 | Q3 24 | Q4 24 | Q1 25 | Q2 25 | Q3 25 | Q4 25 | Year 2025 vs. 2024 |
|---|---|---|---|---|---|---|---|---|---|
| This store | 104 | 101 | 85 | 110 | 108 | 107 | 109 | 113 | +9.4% |
| H-E-B San Antonio (same 45 stores) | 104 | 103 | 93 | 100 | 100 | 103 | 104 | 104 | +2.7% |
| Competitor set | 105 | 104 | 91 | 100 | 100 | 105 | 104 | 102 | +2.8% |
| Other supermarkets | 101 | 101 | 95 | 103 | 95 | 100 | 102 | 107 | +1.2% |
How to read it: the plot reads in two moves. First, the dips all four series share (the Q3 2024 trough) are the market, not the store: read past them. Second, the divergence is the finding: from Q4 2024 on, this store runs 107 to 113 while every reference hovers around 100 to 105. The store outgrew its chain, its competitors, and its category on the same observation ticks.
3. Home and work catchment
Rank and list the most important ZIP codes the store's visitors come from. trade_area_zip carries two trade_area_type values, HOME and WORK, and for this store the pair is the profile:
def catchment(trade_area_type):
rows = run_query("visitation_usa_places.trade_area_zip", {
"fields": [
{"name": "trade_area_location_id"},
{"name": "people_fraction", "aggregation": "AVG"},
],
"filters": [
{"fieldName": "location_id", "operator": "==", "value": STORE},
{"fieldName": "trade_area_type", "operator": "==", "value": trade_area_type},
{"fieldName": "observation_start_date", "operator": ">=", "value": "2025-01-01"},
{"fieldName": "observation_start_date", "operator": "<", "value": "2026-01-01"},
],
"groupBy": [{"fieldName": "trade_area_location_id"}],
"options": {"orderBy": {"fieldName": "people_fraction", "direction": "DESC"}},
"pageSize": 5,
})
return {r["trade_area_location_id"]: float(r["people_fraction_AVG"]) for r in rows}
print("HOME:", catchment("HOME"))
print("WORK:", catchment("WORK"))
Result, 2025 average shares:
| Rank | HOME ZIP | Share | WORK ZIP | Share |
|---|---|---|---|---|
| 1 | 78240 | 8.3% | 78229 | 14.9% |
| 2 | 78229 | 7.0% | 78240 | 6.1% |
| 3 | 78249 | 4.7% | 78249 | 4.3% |
| 4 | 78250 | 4.0% | 78250 | 4.3% |
| 5 | 78228 | 3.7% | 78201 | 4.1% |
How to read it: the home catchment is wide and flat: the top ZIP supplies only 8% of visitors, and the tail stretches across the city. The work catchment is the concentrated one, led by the store's own ZIP 78229, the South Texas Medical Center. Read together: people visit this store from where they work, not from where they live. Many suburban anchors show the opposite pattern, with a third or more of visitors from their own home ZIP.
Origin shares are reported down to a floor, so per-store fractions sum to less than 1.0. Treat them as a ranking, and compare stores by their top origins rather than summing shares.
4. Visitor demographics and profiles
Understand who is visiting this location, through the visitor demographics and visitor profiles datasets. Both are quarterly and return shares (0 to 1) of the location's visitors, and both embed the place fields, so the comparison groups from section 2 come from the same query with a different filter:
audience = run_query("visitation_usa_places.spatialai_visitor_profile", {
"fields": [
{"name": "segment_family"},
{"name": "people_fraction", "aggregation": "AVG"},
],
"filters": [
{"fieldName": "location_id", "operator": "==", "value": STORE},
{"fieldName": "observation_start_date", "operator": ">=", "value": "2025-01-01"},
{"fieldName": "observation_start_date", "operator": "<", "value": "2026-01-01"},
],
"groupBy": [{"fieldName": "segment_family"}],
"options": {"orderBy": {"fieldName": "people_fraction", "direction": "DESC"}},
})
for row in audience[:5]:
print(f'{row["segment_family"]:35} {float(row["people_fraction_AVG"]):.1%}')
The two comparison columns come from the same query with the store filter swapped for brands == "H-E-B" plus city == "San Antonio", and for sub_category == "Supermarkets and Other Grocery (except Convenience) Stores" plus the city. Top lifestyle segments, 2025 (census_visitor_demographics works identically with age, income, education, gender and race bands):
| Segment family | This store | H-E-B San Antonio | SA supermarkets |
|---|---|---|---|
| Melting Pot Families | 27.5% | 28.1% | 39.9% |
| Young Urban Singles | 17.8% | 17.2% | 16.1% |
| Near-Urban Diverse Families | 12.8% | 12.8% | 11.5% |
| Blue Collar Suburbs | 8.4% | 9.8% | 9.0% |
| Wealthy Suburban Families | 7.8% | 7.9% | 4.9% |
How to read it: the store's mix sits within a point or two of the chain's on every segment, and the income and education bands tell the same story. That is itself a finding: these attributes are modeled through visitors' inferred home areas. A store whose visitors come from all over the city because of where they work will show the city's average home profile, whatever distinctive crowd is actually in the aisles. For this store, the catchment and the rhythm carry the character; the demographics confirm it draws the whole city.
5. Before and after the visit
visitor_journey is cross-visitation: for each store, the places its visitors came from (Inflow) or went to (Outflow), with the share of journeys, split by daypart, weekpart, and immediacy (immediate_ = adjacent stop, extended_ = same journey). Again filtered by the resolved location_id:
journeys = run_query("visitation_usa_places.visitor_journey", {
"fields": [
{"name": "related_brands"},
{"name": "related_top_category"},
{"name": "total_journey_fraction", "aggregation": "AVG"},
],
"filters": [
{"fieldName": "location_id", "operator": "==", "value": STORE},
{"fieldName": "flow_direction", "operator": "==", "value": "Outflow"},
{"fieldName": "observation_start_date", "operator": ">=", "value": "2025-10-01"},
{"fieldName": "observation_start_date", "operator": "<", "value": "2026-01-01"},
{"fieldName": "related_brands", "operator": "not in", "values": [""]},
],
"groupBy": [
{"fieldName": "related_brands"},
{"fieldName": "related_top_category"},
],
"options": {"orderBy": {"fieldName": "total_journey_fraction", "direction": "DESC"}},
"pageSize": 12,
})
The result lists the brands visitors go to next, ranked by share of journeys. Q4 2025, outflow, with the inflow from the same query with flow_direction set to "Inflow":
| Next stop (Outflow) | Share | Previous stop (Inflow) | Share |
|---|---|---|---|
| Marshalls | 1.7% | Circle K Gas | 2.2% |
| Walmart | 1.6% | Hooters | 2.2% |
| Walmart Pharmacy | 1.6% | QuikTrip | 2.0% |
| Best Buy | 1.5% | Restore Hyper Wellness | 1.8% |
| VP Racing Fuels | 1.5% | GolfTec | 1.7% |
| H-E-B | 1.2% | Panera Bread | 1.4% |
How to read it: read it in two groups: trip-chain partners (fuel, pharmacy, quick service, errands) and competitor touchpoints (Walmart, Costco further down the list). What stands out here is how flat the list is: no partner above 2.2%, in either direction, where many suburban anchors show a fuel stop alone at close to 10%. The store's visits are embedded in a workday, not a shopping trip, so they chain with little. The one telling row is the 1.2% of visitors who continue to another H-E-B: this is a small format in a medical office building, and part of its traffic is a top-up before the full shop elsewhere. The empty related_brands value means unbranded places; the not in filter hides them.
6. Popular times
popular_times describes the typical weekly traffic signature at the location: for each quarter, an occupancy value per day of week and hour, on a relative scale where 1.0 is the location's busiest hour in the quarter. 168 rows per quarter pivot straight into a week heatmap:
rows = run_query("visitation_usa_places.popular_times", {
"fields": [
{"name": "day_of_week"},
{"name": "day_of_week_name"},
{"name": "hour"},
{"name": "occupancy"},
],
"filters": [
{"fieldName": "location_id", "operator": "==", "value": STORE},
{"fieldName": "observation_start_date", "operator": "==", "value": "2025-10-01"},
],
"pageSize": 168,
})
pt = pd.DataFrame(rows).astype({"day_of_week": int, "hour": int, "occupancy": float})
week = pt.pivot_table(index="day_of_week", columns="hour", values="occupancy")
import matplotlib.pyplot as plt
print(week.sum(axis=1) / week.values.sum()) # share of the week per day
plt.imshow(week, aspect="auto", cmap="viridis") # the week as a heatmap
plt.yticks(range(7), ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"])
Result, Q4 2025 (day_of_week 1 = Sunday):
| Day | Share of the week's occupancy | Peak hour |
|---|---|---|
| Monday | 21.8% | 10:00 |
| Thursday | 21.6% | 09:00 |
| Tuesday | 19.8% | 10:00 |
| Wednesday | 15.5% | 10:00 |
| Friday | 11.8% | 09:00 |
| Saturday | 6.0% | 09:00 |
| Sunday | 3.6% | 11:00 |
How to read it: this is a workweek store. Monday through Thursday carry almost 80% of the week, the peaks land at 9:00 to 10:00 in the morning, and the weekend is nearly dark; a suburban anchor shows the mirror image, weekends first and late-afternoon peaks. The rhythm, the WORK catchment in section 3, and the city-average demographics in section 4 are three views of the same fact about this store: its market is the Medical Center workday.