Finding candidate malls for an in-mall retailer
This example shows how to run analog modeling for an in-mall retailer in Colorado: classify every mall on measured dimensions, join the retailer's own store performance to find the class where it performs, and shortlist the malls of that class with no store yet.
For this we use our US Places metrics, queried from Python. The helper below is all the setup the example needs:
One part of this example is fictional. Meridian Trail Co., its seven stores, and the sales column in section 4 are invented to show how your own performance data joins the classification. Every other number is measured Colorado data for calendar 2025.
import os
import requests
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):
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. The mall universe
Malls carry sub_category Malls in the places data, a category that spans super-regional centers and strip plazas alike. We pull every Colorado mall's monthly visits for 2025, one month per request so each response fits one page, and sum them:
MALL = [
{"fieldName": "sub_category", "operator": "==", "value": "Malls"},
{"fieldName": "us_state", "operator": "==", "value": "Colorado"},
]
rows = []
for month in pd.date_range("2025-01-01", "2025-12-01", freq="MS"):
rows += run_query("visitation_usa_places.foot_traffic_month", {
"fields": [
{"name": "location_id"},
{"name": "location_name"},
{"name": "city"},
{"name": "visits_sum", "aggregation": "SUM"},
],
"filters": MALL + [{"fieldName": "observation_start_date", "operator": "==",
"value": month.strftime("%Y-%m-%d")}],
"groupBy": [{"fieldName": "location_id"}, {"fieldName": "location_name"},
{"fieldName": "city"}],
"pageSize": 10000,
})
df = pd.DataFrame(rows)
df["visits"] = df["visits_sum_SUM"].astype(float)
malls = (df.groupby(["location_id", "location_name", "city"], as_index=False)["visits"].sum()
.query("visits >= 1_000_000")
.set_index("location_id"))
print(len(malls), "malls above one million visits in 2025")
795 malls come back; 428 clear the one-million-visit floor that screens out strip plazas.
2. Tenants, anchors, and visitor profile
Every store inside a mall carries the mall's Placekey as parent_placekey, so the same dataset, filtered to the malls' Placekeys in chunks of fifty, returns each tenant with its own 2025 visits. An anchor is a tenant with at least 300,000 annual visits, roughly a busy department store or supermarket:
ids = list(malls.index)
tenants = []
for i in range(0, len(ids), 50):
tenants += run_query("visitation_usa_places.foot_traffic_month", {
"fields": [
{"name": "location_id"},
{"name": "parent_placekey"},
{"name": "visits_sum", "aggregation": "SUM"},
],
"filters": [
{"fieldName": "parent_placekey", "operator": "in", "values": ids[i:i + 50]},
{"fieldName": "observation_start_date", "operator": ">=", "value": "2025-01-01"},
{"fieldName": "observation_start_date", "operator": "<", "value": "2026-01-01"},
],
"groupBy": [{"fieldName": "location_id"}, {"fieldName": "parent_placekey"}],
"pageSize": 10000,
})
t = pd.DataFrame(tenants)
t["visits"] = t["visits_sum_SUM"].astype(float)
malls["tenants"] = t.groupby("parent_placekey").size()
malls["anchors"] = t[t["visits"] >= 300_000].groupby("parent_placekey").size()
malls[["tenants", "anchors"]] = malls[["tenants", "anchors"]].fillna(0).astype(int)
Who visits comes from two quarterly datasets (Q4 2025 here): census_visitor_demographics for the share of visitors from households earning $125k and above, and spatialai_visitor_profile for the leading lifestyle segment:
demo = run_query("visitation_usa_places.census_visitor_demographics", {
"fields": [{"name": "location_id"}, {"name": "people_fraction_income_125k_and_above"}],
"filters": MALL + [{"fieldName": "observation_start_date", "operator": "==", "value": "2025-10-01"}],
"pageSize": 10000,
})
malls["income_125k"] = (pd.DataFrame(demo).set_index("location_id")
["people_fraction_income_125k_and_above"].astype(float))
profile = []
for i in range(0, len(ids), 150):
profile += run_query("visitation_usa_places.spatialai_visitor_profile", {
"fields": [{"name": "location_id"}, {"name": "segment_family"}, {"name": "people_fraction"}],
"filters": [
{"fieldName": "location_id", "operator": "in", "values": ids[i:i + 150]},
{"fieldName": "observation_start_date", "operator": "==", "value": "2025-10-01"},
],
"pageSize": 10000,
})
p = pd.DataFrame(profile)
p["people_fraction"] = p["people_fraction"].astype(float)
top = p.sort_values("people_fraction", ascending=False).drop_duplicates("location_id").set_index("location_id")
malls["top_segment"] = top["segment_family"]
malls["top_share"] = top["people_fraction"]
Park Meadows' visitors are 47% Ultra Wealthy Families; Mesa Mall in Grand Junction leads with Blue Collar Suburbs at 20%.
3. Classification
With traffic, tenant depth, anchors, and affluence per mall, classification is a few transparent rules:
state_median = malls["income_125k"].median()
def mall_class(m):
scale = ("destination" if m.tenants >= 50 and m.anchors >= 3
else "regional" if m.visits >= 2_000_000 or m.tenants >= 25
else "community")
affluence = "affluent" if m.income_125k >= state_median else "broad"
return f"{scale}-{affluence}"
malls["mall_class"] = malls.apply(mall_class, axis=1)
print(malls["mall_class"].value_counts())
Destination is defined by tenant depth and anchors, not traffic: Aurora City Place, a power center of supermarket and big-box trips, ranks second in the state on traffic (24.5M visits) with 44 tenants, while Twenty Ninth Street in Boulder, ranked 181st (5.0M), has 66 in-line tenants and 6 anchors. For an in-mall retailer, co-tenancy is the signal.
The rules yield 10 destination-affluent, 4 destination-broad, 116 regional-affluent, 121 regional-broad, and 177 community centers. The destination tier, by 2025 traffic:
| Mall | City | Visits | Tenants | Anchors | $125k+ share | Top segment family | Class |
|---|---|---|---|---|---|---|---|
| The Streets At Southglenn | Centennial | 25.1M | 57 | 5 | 40% | Ultra Wealthy Families (31%) | destination-affluent |
| The Shops At Northfield | Denver | 21.9M | 58 | 4 | 24% | Young Professionals (19%) | destination-broad |
| Orchard Town Center | Westminster | 21.1M | 86 | 9 | 32% | Upper Suburban Diverse Families (25%) | destination-affluent |
| Flatiron Crossing | Broomfield | 20.9M | 110 | 4 | 30% | Young Professionals (19%) | destination-affluent |
| Southlands Town Center | Aurora | 20.3M | 108 | 13 | 46% | Wealthy Suburban Families (39%) | destination-affluent |
| Park Meadows | Lone Tree | 20.3M | 149 | 13 | 49% | Ultra Wealthy Families (47%) | destination-affluent |
| Front Range Village | Fort Collins | 20.2M | 64 | 6 | 31% | Upper Suburban Diverse Families (24%) | destination-affluent |
| Cornerstar | Aurora | 19.9M | 50 | 9 | 36% | Wealthy Suburban Families (21%) | destination-affluent |
| Promenade At Castle Rock | Castle Rock | 15.7M | 68 | 15 | 51% | Ultra Wealthy Families (42%) | destination-affluent |
| Southwest Plaza | Littleton | 14.1M | 51 | 6 | 33% | Upper Suburban Diverse Families (32%) | destination-affluent |
| Colorado Mills | Lakewood | 12.1M | 102 | 3 | 27% | Upper Suburban Diverse Families (23%) | destination-broad |
| Twenty Ninth Street | Boulder | 5.0M | 66 | 6 | 30% | Young Professionals (41%) | destination-affluent |
| Town Center At Aurora | Aurora | 4.7M | 56 | 5 | 17% | Young Professionals (20%) | destination-broad |
| Mesa Mall | Grand Junction | 4.4M | 58 | 6 | 14% | Blue Collar Suburbs (20%) | destination-broad |
How to read it: the affluent tier is the metro's suburban centers plus Boulder and Fort Collins; the broad tier is Northfield, Colorado Mills, Town Center at Aurora, and Mesa Mall. Colorado Mills sits just under the state median (27.3% vs. 27.5%); a median split is a choice, and malls this close to the line deserve a look on both sides.
4. Sales by class
This is the fictional step. Meridian Trail Co. operates seven Colorado mall stores with a made-up sales index. With a real network this table is your own sales system keyed by store, and the join is one merge on mall name or Placekey:
sales = pd.DataFrame({
"location_name": ["Park Meadows", "Twenty Ninth Street", "Flatiron Crossing",
"The Promenade Shops At Centerra", "The Shops At Northfield",
"Town Center At Aurora", "The Citadel Mall"],
"sales_index": [100, 96, 91, 73, 61, 48, 44], # fictional
})
network = sales.merge(malls.reset_index()[["location_name", "mall_class"]], on="location_name")
print(network.groupby("mall_class")["sales_index"].agg(["median", "size"]))
| Store (mall) | Class | Sales index (fictional) |
|---|---|---|
| Park Meadows | destination-affluent | 100 |
| Twenty Ninth Street | destination-affluent | 96 |
| Flatiron Crossing | destination-affluent | 91 |
| The Promenade Shops At Centerra | regional-affluent | 73 |
| The Shops At Northfield | destination-broad | 61 |
| Town Center At Aurora | destination-broad | 48 |
| The Citadel Mall | regional-broad | 44 |
Median sales index per class: destination-affluent 96 (3 stores), regional-affluent 73 (1), destination-broad 54.5 (2), regional-broad 44 (1).
How to read it: the classification says what kinds of malls exist; your own sales say which kind performs for you. Here the class medians order cleanly: this retailer performs in destination-affluent malls, holds up in regional-affluent, and underperforms where the visitor base is broad. Read class medians directionally: a class with one store is a data point, not a benchmark, and affluence ordering the same way at both scales carries more weight than any single cell. A value chain may find its best stores in regional-broad centers.
5. Candidate malls
The shortlist is a filter: destination-affluent malls with no store yet.
candidates = malls[(malls["mall_class"] == "destination-affluent")
& ~malls["location_name"].isin(sales["location_name"])]
print(candidates.sort_values("visits", ascending=False)
[["location_name", "city", "visits", "tenants", "anchors", "income_125k", "top_segment"]])
| Candidate mall | City | Visits (2025) | Tenants | Anchors | $125k+ share | Top segment family |
|---|---|---|---|---|---|---|
| The Streets At Southglenn | Centennial | 25.1M | 57 | 5 | 40% | Ultra Wealthy Families (31%) |
| Orchard Town Center | Westminster | 21.1M | 86 | 9 | 32% | Upper Suburban Diverse Families (25%) |
| Southlands Town Center | Aurora | 20.3M | 108 | 13 | 46% | Wealthy Suburban Families (39%) |
| Front Range Village | Fort Collins | 20.2M | 64 | 6 | 31% | Upper Suburban Diverse Families (24%) |
| Cornerstar | Aurora | 19.9M | 50 | 9 | 36% | Wealthy Suburban Families (21%) |
| Promenade At Castle Rock | Castle Rock | 15.7M | 68 | 15 | 51% | Ultra Wealthy Families (42%) |
| Southwest Plaza | Littleton | 14.1M | 51 | 6 | 33% | Upper Suburban Diverse Families (32%) |
How to read it: seven candidates with the traffic, co-tenancy, and visitor profile the network's best stores share. The screen does not know lease availability, rents, formats, or the pipeline; that judgment comes next. Regional-affluent held up in the sales join, so its 116 malls are the second tier: the same filter, one value changed.
Every cut here is a parameter, not a truth: the one-million-visit floor, the 50-tenant and 3-anchor destination bar, the 300k anchor definition, the state median as the affluence split. A luxury brand and a value chain draw these lines differently from the same four queries.