Cutting failed deliveries with clean geocoded addresses

Normalise and geocode every delivery address before it hits dispatch. Batch REST API + web tool patterns that cut failed deliveries at the source.

| June 28, 2026
Cutting failed deliveries with clean geocoded addresses

A failed delivery is not a driver problem. Nineteen times out of twenty it is an address problem — a missing apartment number, a road that changed name three years ago, a postcode that belongs to a sorting office rather than a residential street, or a freeform address field that a customer typed on a mobile phone in five seconds at checkout. By the time the driver is standing at the kerb with an undeliverable parcel, the opportunity to fix the problem cheaply has already passed.

The cheap fix happens at address intake. Normalise and geocode every address when you first receive it — before it enters the warehouse management system, before it is routed, before the driver manifest is printed. A clean, geocoded address with a confidence score is a dispatch-ready record. A raw address string is a liability that your drivers will spend unpaid minutes resolving on their own.

This post shows how to build that intake layer using batch geocoding and address normalisation via REST. You will see the patterns for both the programmatic REST API and the browser-based batch tool, understand how confidence scores become a triage signal, and leave with a Python script and a Node function you can wire in today.

Why address quality kills last-mile efficiency

Last-mile delivery is already the most expensive segment of the supply chain — typically 40-50% of total logistics cost. Failed attempts pile costs on top.

A first-failed attempt triggers a second attempt, which requires re-loading the parcel, re-sequencing the route, and consuming a second driver-hour per stop. If the second attempt fails, the parcel returns to depot, generates a customer service event, and either goes out for a third attempt or triggers a return-to-sender process that costs more than the parcel was worth to ship in the first place.

The failure mode is almost never the driver. It is the address record:

  • Missing sub-premise. "42 Station Road" when the destination is Flat 4, 42 Station Road. The driver stands in a communal entrance with no buzzer number.
  • Transposed digits. "SW1A 2A" instead of "SW1A 2AA". Geocoder gets a low confidence or a wrong neighbourhood.
  • Street renamed or absorbed. "Gasworks Lane" before a development renamed it "Innovation Boulevard". The routing engine routes to null.
  • Industrial or commercial park ambiguity. "Units 1-18, Parkside Business Centre" — which unit is the recipient?
  • Copy-paste errors from mobile checkout. The most creative category. "7 Mayfield Road, Manchestr, M20 6BA" — spell-checker on a mobile keypad is not your friend.

Geocoding does not magically resolve all of these, but it surfaces them at intake time — while a human or an automated rule can still act — rather than at the kerb, where nobody can.

Two surfaces: REST API and the web batch tool

CSV2GEO provides two geocoding surfaces. They share the same address dataset (461M+ addresses across 39 countries) and the same confidence model, but they suit different workflows.

The REST API is for pipeline code. You POST or GET an address, you get a structured JSON response with a normalised address, a coordinate, and a confidence score. Every call is metered against your API key, which you manage at /api-keys. Use this for any integration that moves addresses programmatically: order management systems, WMS ingest hooks, checkout validation endpoints.

The web batch tool is for operational teams who do not write code. Upload a CSV or Excel file, map your column headers to address fields, click geocode, download the enriched file with normalised addresses, coordinates, and confidence scores appended. Credits are consumed per address row, not per file upload. Use this for the weekly manual batch — customer returns, correction queues, third-party consignment files received as flat files.

Do not conflate the two billing models. The REST API is metered per call; the web tool is metered per address row. For a pipeline processing 50,000 orders per day, the REST API is the right surface. For a weekly cleanup of 3,000 returned-parcel records from a third-party carrier, the web tool is faster to operate with zero code.

What address normalisation actually does

Geocoding is popularly understood as "address → lat/lng". That is half the story. The normalised address returned alongside the coordinate is often the more operationally useful output.

When you submit "7 Mayfield Road, Manchestr, M20 6BA", the geocoder corrects the misspelling, validates the postcode, and returns:

{
  "formatted": "7 Mayfield Road, Manchester, M20 6BA, United Kingdom",
  "street_number": "7",
  "street": "Mayfield Road",
  "city": "Manchester",
  "postcode": "M20 6BA",
  "country": "GB",
  "lat": 53.4123,
  "lng": -2.2389,
  "confidence": 0.94
}

That structured breakdown is what your dispatch system should be storing — not the freeform string the customer typed. When the route optimiser ingests the postcode, it gets a valid one. When the driver app renders the turn-by-turn, it geocodes from a coordinate, not from a string that it will re-geocode with unknown confidence. When the customer service agent queries the record, they see a human-readable, validated address — not a typo they have to interpret.

Confidence scores as a triage signal

Every geocoded result carries a confidence score, typically in the range 0.0–1.0. This is not a quality promise from the vendor — it is a signal about how definitively the input matched a known record in the address dataset. High confidence means the match was unambiguous and precise. Low confidence means the geocoder made a best-effort placement that a human should review.

For a delivery pipeline, the practical thresholds are:

| Confidence band | Recommended action | |---|---| | ≥ 0.85 | Dispatch-ready. Store the normalised address and coordinate; no human review required. | | 0.65 – 0.85 | Hold for soft review. Route to a "requires confirmation" queue; send the customer a SMS/email to confirm the address before dispatch. | | < 0.65 | Hard hold. Do not dispatch. Route to a customer service agent for manual validation. |

Your thresholds will drift from these — a same-day courier with aggressive SLAs might lower the soft-review band; a pharmaceutical distributor with regulatory obligations might raise the dispatch-ready band. The point is to set thresholds deliberately, based on your re-delivery cost model, rather than treating all geocoding output as uniform.

The geocoding confidence scores explained post covers the confidence model in depth. Read it before you set your thresholds.

Building the address intake layer: REST API patterns

Step 1: Set up your API key and test with a single address

Your API key lives at csv2geo.com/api-keys. Grab it, drop it in an environment variable, and fire a single test call before writing any pipeline code:

curl -s "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=10 Downing Street London SW1A 2AA" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY" \
  -G | jq '.results[0] | {formatted, lat, lng, confidence}'

If the response comes back with a confidence above 0.9 and the coordinates match central London, your key is working and the endpoint is live. Do not skip this step — a misconfigured key or a network proxy will silently fail in a way that looks like a geocoding accuracy problem later.

Step 2: Normalise addresses at order intake

The cheapest place to catch a bad address is at checkout or at order creation — before the record enters the WMS. A lightweight validation call at order-save time catches transposed postcodes, misspellings, and missing sub-premises when the customer is still on the page and can correct them.

Python, synchronous single-address call:

import os
import requests

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

def geocode_address(raw_address: str) -> dict:
    r = requests.get(
        API,
        params={"q": raw_address, "api_key": KEY},
        timeout=10,
    )
    r.raise_for_status()
    results = r.json().get("results", [])
    if not results:
        return {"normalised": None, "lat": None, "lng": None, "confidence": 0.0}
    top = results[0]
    return {
        "normalised": top.get("formatted"),
        "lat": top.get("lat"),
        "lng": top.get("lng"),
        "confidence": top.get("confidence", 0.0),
    }

result = geocode_address("7 Mayfield Road, Manchestr, M20 6BA")
if result["confidence"] < 0.65:
    raise ValueError(f"Address cannot be validated: {result}")

Node, using native fetch:

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

async function geocodeAddress(rawAddress) {
  const url = `${API}?q=${encodeURIComponent(rawAddress)}&api_key=${KEY}`;
  const r = await fetch(url, { signal: AbortSignal.timeout(10_000) });
  if (!r.ok) throw new Error(`geocode http ${r.status}`);
  const data = await r.json();
  const top = data.results?.[0];
  if (!top) return { normalised: null, lat: null, lng: null, confidence: 0 };
  return {
    normalised: top.formatted,
    lat: top.lat,
    lng: top.lng,
    confidence: top.confidence ?? 0,
  };
}

Both of these belong in the order-save path, not in the dispatch path. You want the failure — a ValueError, a thrown error — to surface to the user while they can still act, not to the driver who is already on the road.

Step 3: Batch geocode existing records nightly

Orders that arrived before you wired in the intake layer, or that came via a B2B EDI feed that does not go through your checkout, accumulate in your WMS as unnormalised address strings. A nightly batch job cleans them up before they reach the morning dispatch run.

Python batch script — pure REST, no SDK:

import csv
import os
import time
import requests

API = "https://csv2geo.com/api/v1/geocode"
KEY = os.environ["CSV2GEO_API_KEY"]
SLEEP_BETWEEN_CALLS = 0.05  # 20 calls/second, well within free-tier limits

def geocode_batch_from_csv(input_path: str, output_path: str) -> None:
    with open(input_path, newline="", encoding="utf-8") as fin, \
         open(output_path, "w", newline="", encoding="utf-8") as fout:

        reader = csv.DictReader(fin)
        out_fields = reader.fieldnames + [
            "normalised_address", "lat", "lng", "confidence", "geocode_status"
        ]
        writer = csv.DictWriter(fout, fieldnames=out_fields)
        writer.writeheader()

        for row in reader:
            raw = row.get("delivery_address", "").strip()
            if not raw:
                row.update(normalised_address="", lat="", lng="",
                           confidence="", geocode_status="empty_input")
                writer.writerow(row)
                continue

            try:
                r = requests.get(
                    API,
                    params={"q": raw, "api_key": KEY},
                    timeout=15,
                )
                r.raise_for_status()
                results = r.json().get("results", [])
                if results:
                    top = results[0]
                    row.update(
                        normalised_address=top.get("formatted", ""),
                        lat=top.get("lat", ""),
                        lng=top.get("lng", ""),
                        confidence=top.get("confidence", ""),
                        geocode_status="ok",
                    )
                else:
                    row.update(normalised_address="", lat="", lng="",
                               confidence="0", geocode_status="no_result")
            except requests.RequestException as exc:
                row.update(normalised_address="", lat="", lng="",
                           confidence="", geocode_status=f"error:{exc}")

            writer.writerow(row)
            time.sleep(SLEEP_BETWEEN_CALLS)

geocode_batch_from_csv("orders_today.csv", "orders_geocoded.csv")

This script writes three categories into geocode_status: ok, no_result, and error:*. Your downstream dispatch job filters for ok with confidence >= 0.85 and routes everything else to a review queue before the manifest is cut. The review queue is where operations teams spend fifteen minutes each morning, not drivers spending thirty minutes each afternoon.

Step 4: Use the web batch tool for operational cleanup

Not every address problem comes through your code pipeline. Third-party consignment sheets, returns manifests from 3PLs, customer address correction spreadsheets from the CS team — these arrive as Excel files at 7am and the operations manager needs them geocoded before the 9am depot sort.

The workflow is:

  1. Navigate to the CSV2GEO web tool (linked from your dashboard).
  2. Upload the Excel or CSV file.
  3. Map your column headers to the recognised address fields — street_number, street, city, postcode, country, or use a single full_address column if your data is unseparated.
  4. Click geocode. Credits are consumed per address row. The free tier covers 3,000 rows per day, which handles most operational cleanup batches without needing a paid plan.
  5. Download the enriched file. It appends normalised_address, lat, lng, and confidence columns to your original data, preserving row order so you can VLOOKUP back to the source system.

The key operational instruction for the operations manager: sort the output by confidence ascending before doing anything with it. The lowest-confidence rows are the problem addresses — review those twenty rows first, before dispatching anything.

Step 5: Route low-confidence records before dispatch

The batch output — whether from the REST API or the web tool — feeds a decision gate before the dispatch manifest is generated. Build the gate as a simple filter in whatever tool your operations team uses:

import csv

DISPATCH_THRESHOLD = 0.85
SOFT_REVIEW_THRESHOLD = 0.65

def triage_geocoded_orders(geocoded_csv_path: str):
    dispatch_ready = []
    soft_review = []
    hard_hold = []

    with open(geocoded_csv_path, newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            confidence = float(row.get("confidence") or 0)
            if row.get("geocode_status") != "ok":
                hard_hold.append(row)
            elif confidence >= DISPATCH_THRESHOLD:
                dispatch_ready.append(row)
            elif confidence >= SOFT_REVIEW_THRESHOLD:
                soft_review.append(row)
            else:
                hard_hold.append(row)

    return dispatch_ready, soft_review, hard_hold

ready, review, hold = triage_geocoded_orders("orders_geocoded.csv")
print(f"Dispatch-ready: {len(ready)}")
print(f"Soft review:    {len(review)}")
print(f"Hard hold:      {len(hold)}")

The dispatch_ready list goes to the route optimiser. The soft_review list triggers a customer confirmation SMS (most operators already have an SMS gateway in their stack — this is a one-line webhook call). The hard_hold list goes to the operations desk. Your drivers never see an unvalidated address.

Failure modes to build around

Three categories of failure that catch teams out when they ship this pattern into production.

Partial address records. B2B order feeds often have city and postcode but no street_number, because the recipient is a loading bay and the buyer assumed the driver knows where that is. The geocoder will return a result — typically the centroid of the postcode or the city — with a confidence around 0.5–0.6. That is correctly a low-confidence result, and correctly routed to soft review. But if your triage logic routes all low-confidence records to "call the customer", and the customer is a purchasing manager at a distribution centre, you will waste everyone's time. Build a separate rule for commercial addresses: low confidence + missing street_number + company_name present → route to "confirm unit number via account manager", not "send customer SMS".

Geocoder results that are correct but imprecise. A geocoder can return the right street with high confidence but land the coordinate at the street midpoint rather than the door. For most last-mile delivery this does not matter — the driver navigates to the street and reads the door number. But for unmanned locker delivery or for geofence-based proof-of-delivery systems, a 40-metre error between geocoder output and actual building entrance is operationally significant. In those cases, supplement the geocoder coordinate with a reverse-geocode check from the driver's GPS at delivery time, and flag records where the geocoder-to-GPS delta exceeds your threshold. The reverse geocoding accuracy and distance meters post covers this measurement in detail.

Rate-limit spikes during peak intake. A flash sale at 8pm generates 40,000 orders in ninety minutes. If your intake validation calls the geocoder synchronously in the order-save path, and you have not built a queue in front of it, you will either hit rate limits or introduce checkout latency. The clean pattern is: save the raw address immediately (allow the order to complete), push a geocoding job to a background queue, and set the order status to address_pending_validation. The geocoding worker processes the queue asynchronously; anything that comes back confidence < 0.65 triggers a customer notification. This keeps the checkout path fast and the geocoding path isolated. See concurrency tuning for geocoding pipelines for the queue depth and worker concurrency numbers that keep you within rate limits.

Stale address normalisation. Addresses change. A new postcode district opens. A road is renamed as part of a development. A building is renumbered. The clean pattern is to re-geocode any address that has not been geocoded in the last 90 days before dispatch. This is cheap — addresses that have not changed will return the same result, and the normalised output is idempotent for matching purposes (see idempotent geocoding — safe to retry). The cost of re-geocoding a 50,000-order daily dispatch run is 50,000 credits — at paid pricing starting from $54/month for 100,000 calls, that is well within the monthly budget for any serious logistics operation.

Cost model for a mid-size operator

A mid-size last-mile operator, 5,000 stops per day, 22 working days per month:

  • 110,000 geocoding calls per month (one per order at intake)
  • Plus ~10,000 re-geocodes per month for stale records and B2B feeds
  • Total: ~120,000 calls/month
  • Paid pricing starts at $54/month for 100,000 calls; the next bracket covers 120,000 comfortably — see the live brackets at csv2geo.com/pricing/api

Compare that against a single re-delivery attempt. If one geocoded address prevents one re-delivery per 1,000 deliveries — a very conservative estimate — the 110,000 calls per month prevent 110 re-deliveries per month. At £11 per failed delivery attempt (a widely cited industry cost floor), that is a £1,210/month cost avoidance from a ~$54/month spend. The ROI calculation is not subtle.

The free tier (3,000 calls/day, no credit card) is enough to pilot the intake normalisation for a week of orders and measure your own failure-rate delta before committing to a paid plan.

Observability: measuring the impact

After you ship the intake layer, you need to measure whether it is working. The two metrics that matter:

Geocode confidence distribution. Log the confidence score for every geocoded address. Plot the histogram weekly. A healthy distribution has a large spike at 0.85+ (your clean orders) and a small tail below 0.65 (your problem addresses). If the tail is growing, your address source quality is degrading — perhaps a new checkout client, a new B2B integration, or a data import from a new acquisition. Catching this via the confidence histogram is faster than catching it via returned-parcel reports three weeks later.

Triage bucket rates. Log the counts of dispatch_ready, soft_review, and hard_hold for every daily batch. The hard_hold rate as a percentage of total orders is your headline quality metric. If it is 2%, you have a problem to investigate. If it drops from 4% to 1.5% after you add postcode validation to your checkout, that is the signal that the improvement worked.

The observability for geocoding pipelines post covers the full instrumentation pattern, including the Prometheus metrics and the alerting rules that fire when your hard_hold rate spikes unexpectedly.

Frequently Asked Questions

Can I geocode international addresses, not just UK ones? Yes — the address dataset covers 39 countries. The confidence model and the normalised address output work the same way regardless of country. For country-specific address format quirks (e.g. Japanese address ordering, German compound street names), see the geocoding addresses in 200+ countries post.

What is the difference between the REST API and the web batch tool for billing purposes? The REST API is metered per API call. The web batch tool is metered per address row. If you upload a 1,000-row CSV in the web tool, you consume 1,000 row credits — not 1,000 API calls in the traditional metered sense. The credits come from the same account balance, but the way they are counted differs. Check csv2geo.com/pricing/api for the current breakdown.

What confidence score should I use as the dispatch-ready threshold? There is no universal answer. Start with 0.85 and measure your re-delivery rate over two weeks. If your re-delivery rate does not drop meaningfully, lower the threshold slightly and see whether the soft-review volume becomes manageable. If the operations team is drowning in soft-review records, raise the threshold. The threshold is a business decision, not a technical one — it trades off re-delivery cost against review labour cost.

Can I use the geocoder to validate addresses at checkout, in real time? Yes, and this is the highest-value deployment of the REST API for e-commerce-driven last-mile operations. A single geocode call takes well under a second on a warm connection. Drop it into the order-save path, return the confidence score to the frontend, and show an inline "did you mean..." prompt when confidence is below 0.7. See the Python and Node snippets in Step 2 above for the exact call pattern.

What happens if the geocoder returns no result for an address? The results array is empty and the geocode_status in your pipeline should be written as no_result. This is distinct from a low-confidence result — it means the geocoder had no candidate match whatsoever. no_result addresses should always go to the hard-hold queue; they cannot be dispatched. In practice, no_result occurs most often for brand-new addresses not yet in the dataset, for entirely fictional addresses, and for heavily malformed inputs.

Should I store the raw address or the normalised address in my WMS? Store both. Keep the raw address exactly as the customer entered it — it is the legal record of what they provided. Store the normalised address and coordinates in separate fields that your dispatch and routing systems consume. This way you can always reproduce what the customer said, and you always dispatch from the clean version.

How do I handle addresses that geocode correctly but to the wrong building — e.g. a new-build estate not yet in the dataset? For new-build addresses not yet in the address dataset, the geocoder will typically return the postcode centroid with a confidence around 0.5–0.6 — which correctly flags the address for soft review. The resolution is usually to add a building note in the delivery instructions ("Block B entrance on the south side of the car park") rather than expecting the geocoder to resolve an address that does not exist in any public record yet.

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 →