Measuring campaign lift by geography with geocoding

Geocode customer and order records, attach boundary codes, then compare targeted vs untargeted areas. A step-by-step guide for marketing analytics teams.

| August 30, 2026
Measuring campaign lift by geography with geocoding

"The campaign worked." This sentence gets said in every post-campaign review, in every market, by every growth team. It is almost never backed by a number that a sceptical CFO could not dismiss in ninety seconds. The usual evidence is a revenue chart that went up during the campaign period, which confounds seasonality, channel mix, a competitor's pricing change, and twenty other things. Occasionally there is a channel-level attribution report, which confounds the campaign with the audience the ad platform already knew was going to convert.

The one thing that can actually isolate campaign effect — and survives scrutiny — is geographic comparison. You targeted some areas and not others. If the targeted areas moved and the comparable untargeted areas did not, you have a defensible lift estimate. If both moved the same, you have a much cheaper way to spend next quarter's budget. Either answer is worth having.

The problem is that to do this analysis you need clean geographic keys on every order and customer record. Postcode, district, admin area — something that is the same unit you used to define your media buys or your field-team territories. Most teams do not have those keys. They have addresses, or they have nothing. This post walks through how to get from a CSV of addresses to a geographic lift analysis, using CSV2GEO's batch geocoding and boundary endpoints as the geography layer.

CSV2GEO provides geography. The statistical comparison — choosing your control areas, your pre/post windows, your aggregation metric — is yours. That split matters, and this post keeps it clean.

Why clean geographic keys change the analysis

An order record with an address is geographically precise but analytically useless until you transform it into the unit your campaign was planned in. If your out-of-home campaign was planned by designated market area and your orders are stored with raw street addresses, you cannot join them. If your regional sales team's territory is defined by postal codes and your e-commerce records have latitude and longitude, you cannot join them either.

The join is the whole problem. Once you have made it — once every order row carries a boundary code that matches your campaign planning layer — the analysis becomes a GROUP BY and a subtraction. The data engineering is harder than the statistics.

Three failure modes that produce wrong conclusions when geographic keys are missing or dirty:

Silent cross-boundary leakage. A campaign targeted at metro area A drives some orders from customers who live in adjacent area B. Without proper boundary assignment those orders either get dropped (understating lift in A) or assigned to the wrong area (overstating lift in B). Address-to-boundary assignment at the record level is the only fix.

Period attribution without geography. You compare total orders in the campaign period against the prior period. Seasonal uplift, a product launch in a different region, and a supply-chain improvement in your warehouse all move the number. The geographic control group — areas that got none of the campaign — controls for all of these simultaneously, because those areas were exposed to the same seasonality and the same product launch.

Postal-code aggregation that crosses campaign boundaries. If your campaign was planned in district-level geography but you aggregate by five-digit postal code, you will have some postal codes that are split — half in the targeted area, half outside it. Those split codes dilute your lift estimate. Boundary assignment should happen at the record level before aggregation, not at the postal-code level after.

All three problems are fixed by the same operation: geocode each record, then assign it to the right boundary polygon before you aggregate. That is the workflow this post describes.

What CSV2GEO gives you for this

Two surfaces matter here.

Batch geocoding via the web tool. Upload a CSV of addresses through the CSV2GEO web batch interface. Credits are consumed per address row. The output adds latitude, longitude, and confidence score to each row. For a historical order file — last quarter's data, last year's data, however far back you are working — this is the fastest way to enrich the whole file in one pass. You pay for the rows you submit; there is no minimum batch size.

The Boundaries / Divisions endpoint (`GET /api/v1/boundaries`). Feed it a latitude and longitude; it returns the boundary codes for that point — postal code, admin level 1 (state/region), admin level 2 (county/district), and so on depending on the country. This is what converts a lat/lng back into the political or statistical geography your campaign was planned in. It covers 63 countries and draws on our 504M+ address reference to assign points accurately, not by point-in-polygon against a low-resolution shapefile.

REST geocoding for ongoing intake. Once you have the historical file enriched, you need every new order geocoded and boundary-assigned at write time, so the next campaign is measurable from day one without another batch enrichment run. The REST endpoint handles this in real time.

The free tier allows 3,000 calls per day, which is enough to prototype the entire pipeline against a representative data slice. Paid plans start at $54/month for 100,000 calls; see csv2geo.com/pricing/api for the current brackets.

The full workflow

Step 1: Prepare your address file for batch geocoding

The batch tool is forgiving about input format but stricter address quality produces better match rates and higher confidence scores. Before uploading:

  • Merge split address fields into a single column. street_1 + ", " + city + ", " + state + " " + zip is the right format for US addresses.
  • Remove known junk rows — internal test orders, warehouse-to-warehouse transfers, PO box addresses that will not geocode to a meaningful point.
  • Flag rows where the address is clearly incomplete (missing city, missing postcode). These will geocode with low confidence; decide whether to exclude them from the analysis entirely or to keep them and filter by confidence threshold at the end.
  • Add an order ID column as a stable join key. The batch tool returns results in input order, but a join key you control is safer than relying on row position.

A minimal CSV looks like this before upload:

order_id,address_full,order_date,revenue
ORD-00001,"14 High Street, Bristol, BS1 4RN",2026-03-12,142.00
ORD-00002,"77 Queen St, Edinburgh, EH2 4NS",2026-03-12,89.50
ORD-00003,"22 George St, Manchester, M2 5WG",2026-03-13,211.00

The batch tool adds lat, lng, and confidence columns and returns the enriched file. At this stage you have a geographically resolved order file but not yet a boundary-assigned one.

Step 2: Assign boundary codes via the Boundaries endpoint

With latitude and longitude on every row, the next step is turning each point into the boundary code that matches your campaign geography. The Boundaries endpoint handles this one point at a time in REST calls, which you orchestrate in a small Python script.

import csv
import os
import time
import requests

API = "https://csv2geo.com/api/v1/boundaries"
KEY = os.environ["CSV2GEO_API_KEY"]

def get_boundary(lat, lng, retries=3):
    for attempt in range(retries):
        try:
            r = requests.get(
                API,
                params={"lat": lat, "lng": lng, "api_key": KEY},
                timeout=15,
            )
            r.raise_for_status()
            data = r.json()
            result = data.get("result", {})
            return {
                "postcode":     result.get("postcode"),
                "admin_level_2": result.get("admin_level_2"),  # district / county
                "admin_level_1": result.get("admin_level_1"),  # region / state
            }
        except requests.HTTPError as e:
            if e.response.status_code == 429:
                time.sleep(2 ** attempt)
            else:
                raise
    return {"postcode": None, "admin_level_2": None, "admin_level_1": None}

with open("orders_geocoded.csv") as fin, \
     open("orders_with_boundaries.csv", "w", newline="") as fout:

    reader = csv.DictReader(fin)
    extra = ["postcode", "admin_level_2", "admin_level_1"]
    writer = csv.DictWriter(fout, fieldnames=reader.fieldnames + extra)
    writer.writeheader()

    for row in reader:
        if not row["lat"] or not row["confidence"]:
            row.update({k: None for k in extra})
            writer.writerow(row)
            continue
        if float(row["confidence"]) < 0.70:
            row.update({k: None for k in extra})
            writer.writerow(row)
            continue
        bounds = get_boundary(row["lat"], row["lng"])
        row.update(bounds)
        writer.writerow(row)

The confidence threshold of 0.70 is a starting point, not a rule. Adjust it based on your data quality — for a campaign analysis where a misclassified record could corrupt a district's aggregate, a higher threshold (0.80) is defensible. Records that fall below the threshold should be set to null boundaries and excluded from the analysis explicitly, not silently dropped from the file.

The same pattern in Node for teams that run this as a background job in a JavaScript service:

import { createReadStream, createWriteStream } from 'node:fs';
import { parse } from 'csv-parse';
import { stringify } from 'csv-stringify';

const API = 'https://csv2geo.com/api/v1/boundaries';
const KEY = process.env.CSV2GEO_API_KEY;

async function getBoundary(lat, lng) {
  const url = `${API}?lat=${lat}&lng=${lng}&api_key=${KEY}`;
  const r = await fetch(url, { signal: AbortSignal.timeout(15000) });
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  const data = await r.json();
  const result = data.result ?? {};
  return {
    postcode:      result.postcode      ?? null,
    admin_level_2: result.admin_level_2 ?? null,
    admin_level_1: result.admin_level_1 ?? null,
  };
}

// Wire into your csv-parse / csv-stringify pipeline per the package docs.

For the historical file this script is a one-off run. For ongoing intake, the same getBoundary() call fires at order-write time and the three boundary columns land in your data warehouse alongside every new row. The next campaign analysis starts from day one with clean geography, not with a backfill job three months later.

The aggregation pattern — grouping by boundary code, summing revenue, computing period-over-period indices — is described more fully in mapping health data to boundaries without storing PII, which works through the same GROUP BY structure for a different domain.

Step 3: Build the campaign geography table

Before you can compare targeted areas against control areas you need a table that says which boundary codes were in the campaign and which were not. This comes from your campaign planning data, not from CSV2GEO — it is the list of postal codes, districts, or regions where media spend was allocated, field teams were deployed, or promotional pricing was active.

A minimal campaign geography table:

boundary_code,boundary_type,campaign_arm,campaign_start,campaign_end
BS1,postcode,targeted,2026-03-01,2026-03-31
BS2,postcode,targeted,2026-03-01,2026-03-31
EH2,postcode,control,2026-03-01,2026-03-31
M2,postcode,control,2026-03-01,2026-03-31

The campaign_arm column distinguishes targeted from control. Control areas should be chosen before you look at results — areas that are demographically and commercially similar to the targeted areas but received none of the campaign spend. The method for choosing matched controls (propensity matching, covariate balancing, or a simpler regional pairing) is your statistician's call. CSV2GEO gives you geography; the causal inference is yours.

Join this table to your orders-with-boundaries file on the boundary code that matches the campaign planning level. If the campaign was planned by postal sector, join on postal sector. If it was planned by admin level 2, join on admin level 2. Mismatched granularity — joining a postcode campaign table to district-level order aggregates — will either drop records or merge distinct areas, both of which bias the result.

Step 4: Aggregate to the period-area level

With the join in place you have one row per order, tagged with boundary code, campaign arm, and campaign period. The aggregation is straightforward SQL or pandas:

import pandas as pd

orders = pd.read_csv("orders_joined.csv", parse_dates=["order_date"])
campaign_start = pd.Timestamp("2026-03-01")
campaign_end   = pd.Timestamp("2026-03-31")
pre_start      = pd.Timestamp("2026-02-01")

def period(date):
    if pre_start <= date < campaign_start:
        return "pre"
    if campaign_start <= date <= campaign_end:
        return "during"
    return "other"

orders["period"] = orders["order_date"].apply(period)

summary = (
    orders[orders["period"].isin(["pre", "during"])]
    .groupby(["postcode", "campaign_arm", "period"])
    .agg(
        order_count=("order_id", "count"),
        total_revenue=("revenue", "sum"),
    )
    .reset_index()
)

summary.to_csv("campaign_summary_by_area.csv", index=False)

The output is one row per (postcode, campaign_arm, period) combination — the unit you will compare. From here the statistics are yours: difference-in-differences, a simple pre/post index comparison, or whatever your analytics team's standard method is. What you cannot do without this step — and what most teams try to do anyway — is say anything meaningful about whether the campaign drove the revenue or the revenue was going to arrive regardless.

Step 5: Wire REST geocoding into ongoing order intake

The historical enrichment is done once. The operational change that makes every future campaign measurable is geocoding each order at write time and storing the boundary keys alongside the revenue and product data.

A minimal intake wrapper in Python:

import os
import requests

GEOCODE_API = "https://csv2geo.com/api/v1/geocode"
BOUNDARY_API = "https://csv2geo.com/api/v1/boundaries"
KEY = os.environ["CSV2GEO_API_KEY"]

def enrich_order(address: str) -> dict:
    """Return lat, lng, confidence, postcode, admin_level_2 for an address."""
    geo = requests.get(
        GEOCODE_API,
        params={"q": address, "api_key": KEY},
        timeout=10,
    )
    geo.raise_for_status()
    result = geo.json()["results"][0]
    lat, lng = result["lat"], result["lng"]
    confidence = result.get("confidence", 0)

    if confidence < 0.70:
        return {"lat": lat, "lng": lng, "confidence": confidence,
                "postcode": None, "admin_level_2": None}

    bounds = requests.get(
        BOUNDARY_API,
        params={"lat": lat, "lng": lng, "api_key": KEY},
        timeout=10,
    )
    bounds.raise_for_status()
    b = bounds.json().get("result", {})
    return {
        "lat": lat,
        "lng": lng,
        "confidence": confidence,
        "postcode": b.get("postcode"),
        "admin_level_2": b.get("admin_level_2"),
    }

Call this function when an order is written to your warehouse and persist the returned fields alongside the order record. From that point forward your order table is campaign-analysis-ready without any future batch enrichment step. The cost is two API calls per order — one geocode, one boundary lookup — which at paid pricing is comfortably under a cent per order at volume.

For teams running this at high throughput, the concurrency and rate-limit patterns discussed in concurrency tuning for geocoding pipelines apply directly. The geocode and boundary calls can be issued in parallel (two concurrent requests per order rather than two sequential ones), which roughly halves the per-order wall-clock time.

The analysis layer: what CSV2GEO does not do

It is worth stating plainly what the two endpoints above give you and what they do not.

CSV2GEO gives you geography: accurate lat/lng per address, and accurate boundary codes per point. It does not provide demographic data, panel data, ad-exposure data, or any attribution modelling. The lift estimate — whether the targeted areas moved more than the control areas, and by how much — is entirely your analysis on your data. The geographic keys are the prerequisite; the inference is yours.

This is the correct split. Any service that offers to hand you a pre-packaged "lift number" without showing you the assumptions is hiding choices that are yours to make: what the pre-period window is, how the control areas were selected, whether to use revenue or order count as the metric, how to handle areas with fewer than a minimum number of observations. Those choices change the answer materially. Own them.

What you can say, once you have done the analysis: "Orders in targeted postal sectors grew X% in the campaign period relative to the pre-period. Orders in matched control sectors grew Y%. The difference is our geographic lift estimate, subject to the assumptions documented in the analysis." That sentence is defensible. "The campaign generated $2.3M in incremental revenue" is usually not, unless you have a very unusual attribution setup.

Getting your address data shaped correctly before the analysis is its own topic; see getting address data ready for BI dashboards for the upstream data-quality work that makes the geocoding step reliable.

Production considerations

Confidence score thresholds and their effect on coverage. Setting the confidence threshold at 0.80 will exclude more records than 0.70. For a campaign in a dense urban market with clean address data, the exclusion rate at 0.80 is typically small. For a campaign in a rural market or a country with inconsistent address formatting, it can be significant. Check the exclusion rate before you run the analysis — if you are excluding 20% of orders in the targeted areas, your lift estimate is based on a non-random sample and may be biased toward the easiest-to-geocode addresses.

Boundary granularity and campaign planning granularity must match. If the media agency planned the campaign at designated-market-area level and you assign orders to postal codes, you need to map each postal code to its parent DMA before joining. This mapping should come from a stable reference table, not from a live API call per row. Build the mapping once, store it, join against it.

Seasonality and pre-period length. A four-week pre-period is often too short to establish a reliable baseline, especially in categories with weekly sales cycles (promotions fall on weekends, pay-day effects skew the first of the month). Use a minimum of eight weeks of pre-period data and check for day-of-week and week-of-month patterns before trusting any pre/during comparison.

Observability on the enrichment pipeline. If the geocoding step degrades — API errors, latency spikes, a batch that ran with a bad API key — you will produce partially-enriched data that poisons the analysis silently. Log call counts, success rates, and confidence score distributions as part of your pipeline monitoring. A sudden drop in mean confidence score across a day's batch is a signal that something upstream changed (address format, data source, input file structure). The pipeline monitoring patterns in observability for geocoding pipelines apply here directly.

Caching boundary lookups. Boundary codes for a given lat/lng do not change on any timescale that matters for campaign analysis. Cache the boundary lookup result in your application layer — keyed by rounded lat/lng to four decimal places — and you avoid redundant API calls for orders placed from the same address. The caching approach in caching geocoding results for 90% cost reduction is directly applicable.

Frequently Asked Questions

Does CSV2GEO provide the campaign targeting data or the demographic data I need for matching?

No. CSV2GEO provides geography — latitude, longitude, and boundary codes per address. Campaign targeting data (which areas received spend, which received field-team effort) comes from your campaign planning tools. Demographic data for matching control areas comes from your data warehouse or from a separate source. The split is intentional: geography is a utility layer; the analytical choices belong to your team.

How many boundary levels does the endpoint return?

The Boundaries endpoint returns multiple administrative levels for a single point — postal code, admin level 2 (district or county equivalent), and admin level 1 (state or region equivalent) where available. Coverage varies by country across the 63 countries supported. For campaign analysis you will typically use one level that matches your planning geography and ignore the others.

What confidence score threshold should I use?

Start at 0.70 and check your exclusion rate. If fewer than 5% of records fall below the threshold, you are in good shape. If the exclusion rate is higher, investigate whether the input addresses have a systematic quality issue — truncated fields, non-standard formatting, country-level formatting inconsistencies — and clean those upstream before re-running.

How do I handle addresses from multiple countries in the same order file?

The geocoding and boundary endpoints handle 63 countries without any country-specific configuration. The boundary codes returned will reflect local administrative geography — a UK postcode sector, a French département, a German Kreis. If your campaign was planned in different geographic units per country, you need a separate mapping table per country that relates the boundary codes returned by the API to your campaign planning units.

Can I use the batch web tool for ongoing intake as well as the historical enrichment?

The batch web tool is designed for file-level jobs — the historical enrichment is the right use case. For ongoing intake at order time, use the REST geocoding and boundary endpoints directly from your application code. The two approaches are complementary: run the batch tool once for history, then wire REST into your intake path for everything going forward.

What happens to orders where the geocoding returns null lat/lng?

A null lat/lng means the address could not be resolved. These records cannot be boundary-assigned and must be excluded from the geographic analysis. Track the null rate as a data-quality metric — a sustained null rate above 2-3% usually signals an upstream data-collection problem (free-text address fields, missing postcode at point of sale) that is worth fixing before the next campaign.

Does the analysis require minimum order volumes per area?

That is a statistical question rather than an API question, but the practical answer is yes. Areas with very low order counts in the pre-period will produce highly volatile lift estimates — a single large order can swing the number by hundreds of percent. Filter out areas below a minimum observation count (typically 30 orders in the pre-period is a starting rule of thumb) before comparing targeted to control. The threshold is your analyst's call.

Related Articles

---

*I.A. / CSV2GEO Creator*

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →