Geographic sampling for market research surveys using geocoding
Geocode your respondent frame, tag each record to an admin boundary, then stratify by area to build geographically balanced survey samples.
Most survey samples cluster where sign-ups are easiest. Dense cities, digitally active demographics, postcodes that over-index on your acquisition channel. If your respondent frame is a CRM export, it inherits the biases of your marketing spend. If it is a purchased panel, it inherits whatever the panel supplier optimised for when recruiting. Either way, the geographic distribution of your sample is almost certainly not the geographic distribution you designed in your study protocol — and the gap between those two things is where the findings go wrong.
The fix is not a bigger sample. It is a geographically stratified sample: one where you tag every record in the frame to a meaningful administrative area, count how many records fall in each area, compare that against your quota targets, and then draw the sample so each area contributes proportionally. The result is a sample that matches your study design instead of your marketing funnel.
CSV2GEO provides three of the four mechanical steps: batch geocoding the frame, attaching each geocoded record to its containing boundary, and resolving the boundary hierarchy when your quotas are defined at a different administrative level than the data arrives in. The fourth step — deciding what the quotas should be — is your job. This post covers the engineering and the failure modes, not the political science.
Why geographic imbalance ruins market research findings
Consider a manufacturer running a brand perception survey across a single mid-sized country. Their CRM has 80,000 records. They want 2,000 completed interviews stratified by region, with each of the country's eight regions contributing roughly 250 completions. When they sample naively from the CRM — random selection, no stratification — they find that Region 4, which contains the capital and the two largest university cities, contributes 1,140 of the 2,000 completions. The rural north and south each contribute fewer than 50.
The resulting dataset is not a regional study. It is a capital-city study with a thin rural garnish. Every cross-tab that claims to show "regional differences" is actually showing "metropolitan versus extremely small rural subsample" differences, and the confidence intervals on the rural cells are too wide to say anything meaningful.
The root cause is not sampling error in the statistical sense. It is frame imbalance driven by geography. The CRM has five times as many records in Region 4 as in any other region because that is where the company's direct-to-consumer channel is strongest. A geographically balanced sample requires knowing, for each of the 80,000 records, which region it belongs to — and then enforcing the quota at draw time.
That region tag is what the Boundaries endpoint gives you. The batch geocoder is what turns address,city,postcode into lat,lng. The combination — geocode, tag, stratify — is the pipeline. Let us build it.
The pipeline in full
Four stages. The first two use the API; the last two use your own logic.
Stage 1: Geocode the frame. Turn every address in your respondent list into a lat/lng coordinate. This is the credit-consuming step — one credit per row in the WEB batch tool, or one credit per address in the /api/v1/geocode endpoint.
Stage 2: Tag each coordinate to its containing boundary. Call the Boundaries endpoint with each lat/lng to retrieve the admin area the point falls inside — county, NUTS region, province, whatever level your study design uses. You can also fetch the full ancestor chain (country → region → county → district) so that if your quotas are defined at region level but your raw data returns at district level, you can roll up.
Stage 3: Count records per area and compare against quotas. This is pure arithmetic: group by the area tag, count rows, divide by total. Compare the resulting distribution against your target distribution. Identify which areas are over-represented and which are under-represented.
Stage 4: Draw the stratified sample. For each area, draw the number of records your quota specifies. If an area has more eligible records than its quota, you draw a random subset. If it has fewer, you have a coverage gap — a methodological decision point, not an API decision point.
Stage 1: Batch geocoding the frame
The WEB batch tool at csv2geo.com is the right starting point for a one-off frame. Upload a CSV; the platform processes each address row and returns a file with lat, lng, and a confidence score appended. Credits consumed equals rows submitted. For a 50,000-record CRM export that is 50,000 credits — covered comfortably within a mid-tier paid plan.
For teams who want programmatic control — scheduled jobs, incremental enrichment as new registrations arrive, integration with an internal data pipeline — the REST endpoint is the right surface. Python example, batching 200 rows per call to the forward geocoder:
import csv, os, time, requests
API = "https://csv2geo.com/api/v1/geocode/batch"
KEY = os.environ["CSV2GEO_API_KEY"]
def geocode_batch(rows):
payload = [{"id": r["respondent_id"], "q": r["address"]} for r in rows]
r = requests.post(API, json={"queries": payload, "api_key": KEY}, timeout=60)
r.raise_for_status()
return {item["id"]: item for item in r.json()["results"]}
with open("frame.csv") as fin, open("frame_geocoded.csv", "w", newline="") as fout:
reader = csv.DictReader(fin)
writer = csv.DictWriter(
fout,
fieldnames=reader.fieldnames + ["lat", "lng", "confidence"]
)
writer.writeheader()
batch = []
for row in reader:
batch.append(row)
if len(batch) == 200:
results = geocode_batch(batch)
for r in batch:
hit = results.get(r["respondent_id"], {})
r["lat"] = hit.get("lat", "")
r["lng"] = hit.get("lng", "")
r["confidence"] = hit.get("confidence", "")
writer.writerow(r)
batch = []
time.sleep(0.2)
if batch:
results = geocode_batch(batch)
for r in batch:
hit = results.get(r["respondent_id"], {})
r["lat"] = hit.get("lat", "")
r["lng"] = hit.get("lng", "")
r["confidence"] = hit.get("confidence", "")
writer.writerow(r)When the frame arrives with coordinates already attached — GPS check-ins, app-collected locations, past-survey lat/lngs — skip the geocoding step and go straight to Stage 2. If some records have addresses and others have coordinates, geocode the address records and pass the coordinate records directly to the boundary lookup. Merge on respondent ID afterwards.
Confidence scores and frame hygiene
Every geocoded result carries a confidence score. Scores below roughly 0.5 indicate the geocoder resolved to postcode centroid or city level rather than to a specific address. For geographic stratification this matters: a record resolved to a city centroid will be tagged to the correct region, but its sub-regional boundary tag is likely wrong. A record resolved to the wrong city entirely — a common failure mode for ambiguous address strings — will be tagged to the wrong region entirely and corrupt your quota counts.
The practical approach: flag low-confidence records, segregate them into a "requires manual review" pile, and exclude them from the automated stratification until they are resolved. A threshold of 0.65–0.70 works for most national studies; tighten it to 0.80 for sub-regional analysis. See Geocoding Confidence Scores Explained for the full treatment of what the score components mean and how to set a defensible threshold for your study.
Stage 2: Tagging each record to its containing boundary
Once every frame record has a lat/lng, call the Boundaries endpoint to retrieve the administrative area it falls inside. The endpoint returns the containing area plus the full ancestor chain, so a single call can tell you that a coordinate is in a particular district, which belongs to a particular county, which belongs to a particular region, which belongs to the country. If your study design uses region-level quotas, you pull the region from the ancestor chain. If it uses county-level quotas, you pull the county.
A curl example:
curl -G "https://csv2geo.com/api/v1/boundaries/containing" \
--data-urlencode "lat=51.5074" \
--data-urlencode "lng=-0.1278" \
--data-urlencode "include_ancestors=true" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"The response carries the containing area's name, its administrative level, its identifier, and — if include_ancestors=true — an array of parent areas from the containing area up to country level.
In Python, integrated with the geocoded frame:
import csv, os, time, requests
API = "https://csv2geo.com/api/v1/boundaries/containing"
KEY = os.environ["CSV2GEO_API_KEY"]
def get_boundary(lat, lng):
r = requests.get(
API,
params={
"lat": lat, "lng": lng,
"include_ancestors": "true",
"api_key": KEY
},
timeout=15
)
if r.status_code == 404:
return None, None # point outside any known boundary
r.raise_for_status()
data = r.json()
area = data.get("area", {})
ancestors = data.get("ancestors", [])
# Find the region-level ancestor (admin_level == 4 in many countries)
region = next(
(a["name"] for a in ancestors if a.get("admin_level") == 4),
None
)
return area.get("name"), region
with open("frame_geocoded.csv") as fin, \
open("frame_tagged.csv", "w", newline="") as fout:
reader = csv.DictReader(fin)
writer = csv.DictWriter(
fout,
fieldnames=reader.fieldnames + ["district", "region"]
)
writer.writeheader()
for row in reader:
if not row["lat"] or not row["lng"]:
row["district"] = ""
row["region"] = ""
else:
district, region = get_boundary(row["lat"], row["lng"])
row["district"] = district or ""
row["region"] = region or ""
writer.writerow(row)
time.sleep(0.05) # stay well inside rate limitsThe same pattern in Node:
import { createReadStream, createWriteStream } from 'node:fs';
import { parse } from 'csv-parse';
import { stringify } from 'csv-stringify';
const API = 'https://csv2geo.com/api/v1/boundaries/containing';
const KEY = process.env.CSV2GEO_API_KEY;
async function getBoundary(lat, lng) {
const url = `${API}?lat=${lat}&lng=${lng}&include_ancestors=true&api_key=${KEY}`;
const r = await fetch(url);
if (r.status === 404) return { district: '', region: '' };
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
const district = data.area?.name ?? '';
const region = (data.ancestors ?? [])
.find(a => a.admin_level === 4)?.name ?? '';
return { district, region };
}One thing worth noting: the admin_level values used in the ancestor chain follow a widely used international convention for administrative hierarchy. The exact number for "region" varies by country — level 4 is common in Europe, while some countries use level 5 or 6 for what they call a province or state. Check the response for a couple of representative coordinates in your study geography before wiring in a hardcoded level number.
Stage 3: Counting records per area
Once the frame is tagged, the count step is straightforward. In Python using the standard library:
import csv
from collections import Counter
region_counts = Counter()
total = 0
with open("frame_tagged.csv") as f:
for row in csv.DictReader(f):
if row["region"]:
region_counts[row["region"]] += 1
total += 1
print(f"Total tagged records: {total}")
for region, count in region_counts.most_common():
print(f" {region}: {count} ({100*count/total:.1f}%)")Compare the output against your quota table. The quota table is yours — CSV2GEO does not provide census counts, demographic targets, or panel sizes. Your quotas come from whatever the study design specifies: population proportional to a national census, equal allocation across regions, or a custom weighting scheme supplied by the client. The API gives you the geographic tag; the quota numbers come from your research brief.
A typical output might look like this for a national study:
Total tagged records: 47,832
Region 4 (Capital): 19,112 (40.0%)
Region 2 (North-West): 5,814 (12.2%)
Region 6 (South): 5,203 (10.9%)
Region 1 (North-East): 4,991 (10.4%)
Region 7 (West): 4,440 (9.3%)
Region 3 (Central): 4,127 (8.6%)
Region 5 (East): 2,982 (6.2%)
Region 8 (Islands): 1,163 (2.4%)If your target allocation is equal-weight across regions (250 completions each, 2,000 total), Region 4 is massively over-represented in the frame and the Islands region is marginal. You have enough records in every region to hit 250 completions — but in the Islands you are drawing almost your entire eligible pool, which means non-response there will be a problem. Flag it now, before fieldwork starts.
Stage 4: Drawing the stratified sample
The draw step is pure logic. For each region, draw min(quota[region], available[region]) records at random without replacement. If available[region] < quota[region], log the shortfall and decide at the research level whether to accept a smaller cell, boost the frame in that region, or re-weight in analysis.
import csv, random
from collections import defaultdict
QUOTAS = {
"Region 1 (North-East)": 250,
"Region 2 (North-West)": 250,
"Region 3 (Central)": 250,
"Region 4 (Capital)": 250,
"Region 5 (East)": 250,
"Region 6 (South)": 250,
"Region 7 (West)": 250,
"Region 8 (Islands)": 250,
}
pool = defaultdict(list)
with open("frame_tagged.csv") as f:
for row in csv.DictReader(f):
if row["region"] in QUOTAS:
pool[row["region"]].append(row)
sample = []
for region, quota in QUOTAS.items():
available = pool[region]
if len(available) < quota:
print(f"WARNING: {region} has {len(available)} records, quota is {quota}")
drawn = random.sample(available, min(quota, len(available)))
sample.extend(drawn)
with open("sample.csv", "w", newline="") as fout:
writer = csv.DictWriter(fout, fieldnames=sample[0].keys())
writer.writeheader()
writer.writerows(sample)
print(f"Sample drawn: {len(sample)} records")The output is a clean CSV: one row per sampled respondent, with their original fields plus the geocoded coordinates plus the region tag. Feed it into your survey platform, your CATI system, or your panel recruitment workflow.
How to implement this end to end
Step 1: Audit the frame before geocoding
Before spending credits, audit the address data quality. Spot-check 50 records manually. Look for malformed strings, truncated fields, address-line-one that is actually a PO Box, country codes missing from international records. A 5% bad-address rate on a 50,000-record frame means 2,500 records that will geocode to city centroid or fail entirely. Fix what you can fix cheaply; flag the rest for post-geocoding confidence-score filtering.
Step 2: Geocode the frame in batches and log confidence
Run the batch geocoder. Write the output — lat, lng, confidence — back to your frame immediately. Do not proceed to the boundary-tagging step until you have a distribution of confidence scores. If more than 10% of records score below 0.60, the address data is too dirty for automated stratification and needs a cleaning pass first.
Step 3: Tag each geocoded record to its containing admin area
Call the Boundaries endpoint for every record with a usable lat/lng. Use include_ancestors=true and extract the admin level that matches your quota design. Write the area name and any parent areas as separate columns. Records that return a 404 from the boundary endpoint — points on coastlines, in international waters, or at coordinates the geocoder resolved incorrectly — go into a review pile.
Step 4: Build the count table and compare against quotas
Group by your target admin level. Count records per area. Express as a percentage of total. Lay this against your quota table. Identify shortfall regions (where the frame has fewer records than your quota) and surplus regions (where it has more). A shortfall region is a methodological risk; document it. A surplus region is not a problem — you will simply draw a random subset.
Step 5: Draw the sample, validate the output, and document the method
Draw the stratified sample using the logic above. Validate: re-run the count table on the drawn sample and confirm it matches your quotas. Document the frame size, the geocoding pass rate, the confidence threshold used, the boundary level used, and any shortfall regions. This documentation is the methodology section of your research report. Do it now while the code is in front of you; do not reconstruct it from memory three weeks later.
Handling frames that arrive as coordinates
Some frames arrive with coordinates rather than addresses: GPS locations from a mobile survey tool, check-in data from an app, historical survey lat/lngs. These skip the geocoding step entirely and go straight to the boundary lookup.
If you need to confirm an address for the sample record — for a postal mail-out, for example — call the reverse geocoding endpoint instead:
curl -G "https://csv2geo.com/api/v1/reverse" \
--data-urlencode "lat=48.8566" \
--data-urlencode "lng=2.3522" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"The response returns the nearest address to the coordinate. One credit per call. The same confidence-score discipline applies: a reverse-geocoded address for a coordinate in a sparse rural area may resolve to a road name without a house number, which is fine for regional stratification but not adequate for a postal dispatch.
Production failure modes to anticipate
Three things that reliably bite teams who build this pipeline and then run it under pressure.
Frame records that geocode to the wrong country. A customer who moved from France to Canada but never updated their address in your CRM will geocode to France. If your study is Canada-only, that record will be excluded by the boundary lookup — the boundary endpoint will return a French region tag, not a Canadian one. This is the correct behaviour. The failure mode is when teams do not notice the exclusion and do not account for it in the final sample size. Log every record whose boundary tag does not match the expected study geography.
Admin level mismatch between countries in a multi-country study. If your study spans several countries and you are pulling admin_level == 4 to represent "region," some countries will return what they call a province at that level, others a county, others a metropolitan area. Audit the boundary responses for two or three coordinates per country before wiring in a single admin-level constant. The Boundaries endpoint's ancestors array returns the level label alongside the name — use that label to validate that you are pulling the right tier.
Rate limit pressure during large frame runs. A 100,000-record frame, each requiring a boundary lookup, is 100,000 API calls. At modest concurrency this runs comfortably, but if you fire all requests at once you will hit the rate limit and start receiving 429 responses. Implement token-bucket throttling on the client side — see Rate Limiting — Token Bucket vs Leaky Bucket for the pattern — and add exponential backoff on any 429 or 5xx response. A clean run at 10 concurrent requests with 50 ms minimum inter-request gap will complete a 100,000-record tagging job in under four hours without a single rate-limit error.
Cost model for a typical research project
A single national study with a 50,000-record frame, processed once:
- 50,000 geocoding credits (one per address row)
- 50,000 boundary-lookup credits (one per geocoded record)
- Total: 100,000 credits
At paid pricing starting from $54/month for 100,000 calls (published at csv2geo.com/pricing/api), a single study frame sits within the entry paid tier. The free tier — 3,000 calls per day — covers pilot work and methodology testing without a credit card: 3,000 calls per day is enough to process a pilot frame of several thousand records in a single day.
For a research agency running eight to twelve studies per year, each with frames between 20,000 and 80,000 records, a mid-tier plan covers the annual volume comfortably. Cache the boundary tags in your frame management database — once a record is tagged, there is no reason to re-call the boundary endpoint on subsequent runs unless the record's address changed. See Caching Geocoding Results — 90% Cost Reduction for the caching pattern; it applies verbatim to boundary tags.
Frequently Asked Questions
Does CSV2GEO provide census data or population weights for the quota calculations?
No. CSV2GEO provides the geographic tag — the administrative area each respondent falls inside — and the boundary hierarchy. The quota numbers themselves come from your research brief, your client's study design, or external demographic data you source separately. The API is the geocoding and boundary layer; the sampling arithmetic is yours.
What admin levels are available in the boundary hierarchy?
The available levels vary by country, following the administrative hierarchy of each jurisdiction. Most supported countries return at minimum country, region, and district level. Use include_ancestors=true on the Boundaries endpoint and inspect the admin_level field on each ancestor to understand what is available for your study geography before wiring in a hardcoded level number.
What if a record's address geocodes to a confidence score that is too low to trust?
Exclude it from automated stratification. Write it to a separate review file. Depending on volume, either clean the address manually and re-geocode, or treat it as a frame gap and document it in the methodology. Do not carry a low-confidence geocode through to the boundary tag — the region assignment will be wrong and will quietly corrupt your quota counts.
How do I handle respondents who move between when the frame was built and when fieldwork runs?
You do not, and neither does the API. The boundary tag reflects the address in the frame at the time of geocoding. For tracking studies that re-use frames across waves, re-geocode the frame at the start of each wave and re-tag boundaries. The incremental cost is small; the methodological integrity gain is real.
Can I use this workflow for sub-national studies — a single city, a single metropolitan area?
Yes. The boundary lookup returns areas at whatever level you request, including finer-grained divisions like boroughs, arrondissements, or wards. Use the children endpoint to list the sub-areas within your study geography, then tag each record to the appropriate sub-area. The stratification logic is identical; only the admin level changes.
Is there an equivalent workflow for frames that only have postal codes, not full addresses?
Yes, with caveats. Geocoding a postal code returns the centroid of that postcode, which is sufficient for regional stratification but not for sub-regional work in areas with large or oddly shaped postcodes. If your frame has postcodes only, geocode them, use the resulting centroids for the boundary lookup, and document the centroid assumption in your methodology. For studies where sub-regional precision matters, push back on the data supplier to provide full addresses.
How does this differ from the health-data boundary mapping workflow?
The geocode-to-boundary-to-aggregate shape is the same. The difference is the endpoint in the middle: health mapping typically aggregates counts per area for visualisation or statistical modelling; survey sampling draws individual records per area to meet a quota. The boundary tags serve both purposes — the aggregation or selection logic on top is different.
Related Articles
- Benchmarking geocoding APIs — honest numbers — what to measure when evaluating geocoding quality for a research frame
- Reverse geocoding accuracy in meters — understanding what "accurate" means when coordinates are your starting point
- Caching geocoding results — 90% cost reduction — cache boundary tags alongside geocodes to avoid re-calling the API on repeat runs
- Observability for geocoding pipelines — instrument your frame-processing job so you catch confidence-score degradation before it corrupts the sample
- Geocoding confidence scores explained — how to set a defensible confidence threshold for your study design
---
*I.A. / CSV2GEO Creator*
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →