Back
GuidesStore ProfilingProfiling an H-E-B store in San Antonio
Python
USA

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.

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):

Month20242025
Jan9799
Feb8788
Mar9096
Apr9095
May9696
Jun7991
Jul7394
Aug7795
Sep7398
Oct104104
Nov9294
Dec9398

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:

SeriesQ1 24Q2 24Q3 24Q4 24Q1 25Q2 25Q3 25Q4 25Year 2025 vs. 2024
This store10410185110108107109113+9.4%
H-E-B San Antonio (same 45 stores)10410393100100103104104+2.7%
Competitor set10510491100100105104102+2.8%
Other supermarkets1011019510395100102107+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:

RankHOME ZIPShareWORK ZIPShare
1782408.3%7822914.9%
2782297.0%782406.1%
3782494.7%782494.3%
4782504.0%782504.3%
5782283.7%782014.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 familyThis storeH-E-B San AntonioSA supermarkets
Melting Pot Families27.5%28.1%39.9%
Young Urban Singles17.8%17.2%16.1%
Near-Urban Diverse Families12.8%12.8%11.5%
Blue Collar Suburbs8.4%9.8%9.0%
Wealthy Suburban Families7.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)SharePrevious stop (Inflow)Share
Marshalls1.7%Circle K Gas2.2%
Walmart1.6%Hooters2.2%
Walmart Pharmacy1.6%QuikTrip2.0%
Best Buy1.5%Restore Hyper Wellness1.8%
VP Racing Fuels1.5%GolfTec1.7%
H-E-B1.2%Panera Bread1.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.

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):

DayShare of the week's occupancyPeak hour
Monday21.8%10:00
Thursday21.6%09:00
Tuesday19.8%10:00
Wednesday15.5%10:00
Friday11.8%09:00
Saturday6.0%09:00
Sunday3.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.

Did you find what you were looking for?