Skip to main content
29M UK properties, one API call. Get your API key →
Developer & API 10 min read

UK postcode geocoding API: convert postcodes to coordinates (Python & JS)

Convert any UK postcode to latitude/longitude coordinates, plus UPRN-level precision for individual properties in Great Britain. Includes batch geocoding, proximity search, and distance calculations with Python and JavaScript examples.

Homedata Team · · Updated

The problem: postcodes aren't coordinates

Every UK property has a postcode. But if you're building a mapping feature, calculating distances, or running geospatial queries, you need latitude and longitude, not a string like "SW1A 1AA". Converting postcodes to coordinates is one of the most common data tasks in UK property tech.

UPRN-level coordinates in the UK trace back to Ordnance Survey's OS Open UPRN dataset, whose April 2026 release is the current quarterly refresh.

Homedata answers this two ways, and which one you want depends on what the coordinate is for:

  • Postcode centroid: one call to /postcode-profile/, 1 token, accurate to roughly 100 metres. The centre point of the postcode.
  • Property coordinates: two calls, 7 tokens, accurate to about a metre. /address/postcode/{postcode}/ for the addresses, then /property/{uprn}/address/ for the one you want.

The centroid is the right unit for anything area-shaped: heatmaps, catchment areas, plotting where your customers are, clustering. It is the wrong unit for anything that points at a building. A single postcode covers 15–100 addresses spread over several streets, so a centroid used for a map pin, a delivery route or a join to another property dataset is wrong by a whole street, and wrong invisibly, until someone checks it against the pavement.

One call: postcode centroid

If you want the centre point of a postcode rather than a specific building, /postcode-profile/ returns it in a single call for 1 token, alongside its area statistics.

cURL: 1 token
curl "https://api.homedata.co.uk/postcode-profile/?postcode=SW1A1AA" \
  -H "Authorization: Api-Key hd_live_your_key_here"

# {
#   "postcode": "SW1A 1AA",
#   "outcode": "SW1A",
#   "location": { "lat": 51.501008, "lng": -0.141588 },
#   ...
# }

Data source: Ordnance Survey Code-Point Open and the ONS Postcode Directory, published under the Open Government Licence v3.0.

That is the cheapest correct answer for area-level work. For a coordinate that points at a building, keep reading.

Quick start: postcode to property coordinates

Two calls, seven tokens. The first turns a postcode into its addresses; the second turns one of those addresses into coordinates.

cURL: 2 tokens
curl "https://api.homedata.co.uk/address/postcode/SW1A2AA/" \
  -H "Authorization: Api-Key hd_live_your_key_here"

# { "postcode": "SW1A 2AA", "count": 1,
#   "addresses": [ { "uprn": 100023336956, "uprn_token": "gAAAAABm4xK…",
#                    "address": "PRIME MINISTER & FIRST LORD OF THE TREASURY, 10 DOWNING STREET, LONDON, SW1A 2AA",
#                    "building_number": "10", "street": "Downing Street", "town": "London" } ] }
cURL: 5 tokens
curl "https://api.homedata.co.uk/property/100023336956/address/" \
  -H "Authorization: Api-Key hd_live_your_key_here"
Response (truncated)
{
  "uprn": 100023336956,
  "full_address": "10 DOWNING STREET, LONDON, SW1A 2AA",
  "address_line_1": "10 Downing Street",
  "building_number": "10",
  "street_name": "Downing Street",
  "town_name": "London",
  "postcode": "SW1A 2AA",
  "outward_postcode": "SW1A",
  "latitude": 51.5033,
  "longitude": -0.1276
}

Coordinates arrive as WGS84 latitude/longitude, ready for any web map.

Python: batch geocode postcodes

Here's a complete Python script that geocodes a list of buildings and writes the results to a CSV file. It handles rate limiting, and it refuses to guess: a postcode with several addresses and no building named is recorded as a failure rather than resolved to whichever property came back first.

geocode_postcodes.py Python 3.9+
import requests
import csv
import time

API_KEY = "hd_live_your_key_here"
BASE_URL = "https://api.homedata.co.uk"

class AmbiguousPostcode(Exception):
    """A postcode identified more than one property and no building was given."""

    def __init__(self, postcode: str, addresses: list[dict]):
        self.postcode = postcode
        self.addresses = addresses
        super().__init__(
            f"{postcode} has {len(addresses)} registered addresses. "
            f"Pass building= to choose one, e.g. building=\"{addresses[0]['address']}\"."
        )


def geocode_address(postcode: str, building: str | None = None) -> dict | None:
    """
    Convert a postcode plus a building to that property's exact coordinates.
    Two calls: addresses at the postcode (2 tokens), then the property (5 tokens).

    A postcode is NOT a property. Most postcodes cover 15-100 addresses, so this
    refuses to guess: pass `building` unless the postcode has exactly one address.
    Returns None if nothing matches; raises AmbiguousPostcode if you did not choose.
    """
    # Normalise: strip spaces for the URL path
    clean = postcode.replace(" ", "").upper()

    # Step 1: postcode -> addresses, each with a UPRN
    resp = requests.get(
        f"{BASE_URL}/address/postcode/{clean}/",
        headers={"Authorization": f"Api-Key {API_KEY}"},
        timeout=10,
    )
    if resp.status_code == 404:
        return None  # invalid or terminated postcode
    resp.raise_for_status()

    addresses = resp.json().get("addresses", [])
    if not addresses:
        return None  # postcode is live but has no registered addresses

    if building is not None:
        wanted = building.strip().casefold()
        matches = [a for a in addresses if wanted in a["address"].casefold()]
        if len(matches) != 1:
            return None  # no match, or still ambiguous - do not pick one
        uprn = matches[0]["uprn"]
    elif len(addresses) == 1:
        uprn = addresses[0]["uprn"]
    else:
        # Silently taking addresses[0] here is the exact error this article is
        # about: you would map a neighbour and never find out.
        raise AmbiguousPostcode(postcode, addresses)

    # Step 2: UPRN -> coordinates
    detail = requests.get(
        f"{BASE_URL}/property/{uprn}/address/",
        headers={"Authorization": f"Api-Key {API_KEY}"},
        timeout=10,
    )
    detail.raise_for_status()

    data = detail.json()
    return {
        "postcode": data.get("postcode", postcode),
        "uprn": uprn,
        "address": data.get("full_address"),
        "latitude": data.get("latitude"),
        "longitude": data.get("longitude"),
    }


def batch_geocode(locations: list[tuple[str, str]], output_csv: str):
    """Geocode (postcode, building) pairs and write results to CSV."""
    results = []
    failed = []

    for i, (pc, building) in enumerate(locations, 1):
        print(f"  [{i}/{len(locations)}] Geocoding {building}, {pc}...", end=" ")

        try:
            result = geocode_address(pc, building)
            if result:
                results.append(result)
                print(f"→ ({result['latitude']}, {result['longitude']})")
            else:
                failed.append(pc)
                print("→ no single address matched")
        except AmbiguousPostcode as e:
            # Recorded as a failure, never resolved by guessing.
            failed.append(pc)
            print(f"→ {e}")
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 429:
                print("→ rate limited, waiting 2s...")
                time.sleep(2)
                # Retry once
                try:
                    result = geocode_address(pc, building)
                    if result:
                        results.append(result)
                except Exception:
                    failed.append(pc)
            else:
                failed.append(pc)
                print(f"→ error: {e}")

        # Pause briefly between requests
        time.sleep(0.5)

    # Write CSV
    if results:
        with open(output_csv, "w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=results[0].keys())
            writer.writeheader()
            writer.writerows(results)

    print(f"\nDone: {len(results)} geocoded, {len(failed)} failed → {output_csv}")
    if failed:
        print(f"Failed postcodes: {', '.join(failed)}")

    return results


# Example: geocode office locations. Each needs a building, because a postcode
# on its own does not identify one - which is the whole argument of this article.
locations = [
    ("SW1A 2AA", "10 Downing Street"),
    ("EC2R 8AH", "Bank of England Museum Shop"),   # "Bank of England" alone matches four
    ("M1 1AE", "Flat 7, 113 Newton Street"),       # 21 flats share the building
]

batch_geocode(locations, "office_locations.csv")

JavaScript / Node.js: geocode in your API

For server-side JavaScript applications: a common pattern when building Express/Fastify backends that need to enrich incoming data with coordinates.

geocode.ts TypeScript / Node.js
const API_KEY = 'hd_live_your_key_here';
const BASE_URL = 'https://api.homedata.co.uk';

interface GeocodedAddress {
  postcode: string;
  uprn: number;
  latitude: number;
  longitude: number;
  fullAddress?: string;
}

/**
 * A postcode is not a property: most cover 15-100 addresses. Pass `building`
 * unless the postcode has exactly one, and this returns null rather than
 * guessing which neighbour you meant.
 */
async function geocodeAddress(
  postcode: string,
  building?: string,
): Promise<GeocodedAddress | null> {
  const clean = postcode.replace(/\s/g, '').toUpperCase();
  const headers = { 'Authorization': `Api-Key ${API_KEY}` };

  // Step 1: postcode -> addresses, each with a UPRN (2 tokens)
  const list = await fetch(`${BASE_URL}/address/postcode/${clean}/`, { headers });
  if (list.status === 404) return null;
  if (!list.ok) throw new Error(`API error: ${list.status}`);

  const { addresses = [] } = await list.json();
  if (addresses.length === 0) return null;

  let chosen;
  if (building !== undefined) {
    const wanted = building.trim().toLowerCase();
    const matches = addresses.filter(
      (a) => a.address.toLowerCase().includes(wanted),
    );
    if (matches.length !== 1) return null; // no match, or still ambiguous
    chosen = matches[0];
  } else if (addresses.length === 1) {
    chosen = addresses[0];
  } else {
    return null; // ambiguous - say which building you mean
  }

  // Step 2: UPRN -> coordinates (5 tokens)
  const uprn = chosen.uprn;
  const detail = await fetch(`${BASE_URL}/property/${uprn}/address/`, { headers });
  if (!detail.ok) throw new Error(`API error: ${detail.status}`);

  const data = await detail.json();
  return {
    postcode: data.postcode,
    uprn,
    latitude: data.latitude,
    longitude: data.longitude,
    fullAddress: data.full_address,
  };
}

/**
 * Calculate straight-line distance between two points (Haversine formula).
 * Returns distance in kilometres.
 */
function haversineKm(
  lat1: number, lng1: number,
  lat2: number, lng2: number
): number {
  const R = 6371; // Earth's radius in km
  const dLat = (lat2 - lat1) * Math.PI / 180;
  const dLng = (lng2 - lng1) * Math.PI / 180;
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(lat1 * Math.PI / 180) *
    Math.cos(lat2 * Math.PI / 180) *
    Math.sin(dLng / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

// Example: distance between two specific buildings
async function distanceBetween(
  from: [string, string],
  to: [string, string],
): Promise<string> {
  const [a, b] = await Promise.all([
    geocodeAddress(...from),
    geocodeAddress(...to),
  ]);

  if (!a || !b) return 'Could not resolve one or both addresses to a single property';

  const km = haversineKm(a.latitude, a.longitude, b.latitude, b.longitude);
  return `${a.fullAddress} → ${b.fullAddress}: ${km.toFixed(1)} km`;
}

// Run
distanceBetween(
  ['SW1A 2AA', '10 Downing Street'],
  ['EC2R 8AH', 'Bank of England Museum Shop'],  // 'Bank of England' alone matches four
).then(console.log);

UPRN-level precision: exact property coordinates

For property-level applications, valuation, surveying, delivery routing, postcode centroids aren't precise enough. A single postcode can cover 15–100 properties spread across several streets. The Homedata address retrieve endpoint returns exact UPRN coordinates:

Step 1: Find addresses at a postcode
curl "https://api.homedata.co.uk/address/postcode/SW1A2AA/" \
  -H "Authorization: Api-Key hd_live_your_key_here"

# Returns:
# {
#   "postcode": "SW1A 2AA",
#   "count": 1,
#   "addresses": [
#     { "uprn": 100023336956, "uprn_token": "gAAAAABm4xK…",
#       "address": "PRIME MINISTER & FIRST LORD OF THE TREASURY, 10 DOWNING STREET, LONDON, SW1A 2AA",
#       "building_number": "10", "street": "Downing Street", "town": "London" }
#   ]
# }
#
# Either uprn or uprn_token works in any {uprn} endpoint. Store the numeric
# uprn: the token is re-generated and will not match on a later call.
Step 2: Get exact coordinates for a UPRN
curl "https://api.homedata.co.uk/property/100023336956/address/" \
  -H "Authorization: Api-Key hd_live_your_key_here"

# Returns the address fields, including exact coordinates:
# {
#   "uprn": 100023336956,
#   "full_address": "10 DOWNING STREET, LONDON, SW1A 2AA",
#   "postcode": "SW1A 2AA",
#   "latitude": 51.5033,
#   "longitude": -0.1276
# }

Use case: how far is a property from a point

A common pattern: given a centre point (an office, a school, a train station), work out how far each property of interest actually is from it. Because coordinates are property-level, the distance is between two buildings rather than between two area averages.

One thing to know before you build it: listing search filters by area, not by radius. Pass boundary_id (from /boundaries/autocomplete/) or an exact postcode, then measure distance yourself on the properties you care about. Search results do not carry coordinates: those come from the property call below.

proximity_search.py Python 3.9+
import requests
import math

API_KEY = "hd_live_your_key_here"
BASE = "https://api.homedata.co.uk"

def haversine_km(lat1, lng1, lat2, lng2):
    """Calculate distance between two points in kilometres."""
    R = 6371
    dlat = math.radians(lat2 - lat1)
    dlng = math.radians(lng2 - lng1)
    a = (math.sin(dlat / 2) ** 2 +
         math.cos(math.radians(lat1)) *
         math.cos(math.radians(lat2)) *
         math.sin(dlng / 2) ** 2)
    return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))


HEADERS = {"Authorization": f"Api-Key {API_KEY}"}


def coordinates_for(uprn: int) -> tuple[float, float, str]:
    """Exact coordinates for one property. 5 tokens."""
    data = requests.get(
        f"{BASE}/property/{uprn}/address/",
        headers=HEADERS,
        timeout=10,
    ).json()

    return data["latitude"], data["longitude"], data["full_address"]


def uprns_at(postcode: str) -> list[int]:
    """Every registered address at a postcode. 2 tokens."""
    clean = postcode.replace(" ", "").upper()
    addresses = requests.get(
        f"{BASE}/address/postcode/{clean}/",
        headers=HEADERS,
        timeout=10,
    ).json().get("addresses", [])

    return [a["uprn"] for a in addresses]


def distances_from(centre_uprn: int, candidate_postcode: str, radius_km: float = 1.0):
    """
    Distance from one property to every address at a postcode, nearest first.
    Filtering happens on your side: the API has no radius parameter.
    """
    centre_lat, centre_lng, centre_address = coordinates_for(centre_uprn)
    print(f"Centre: {centre_address} ({centre_lat}, {centre_lng})")

    nearby = []
    for uprn in uprns_at(candidate_postcode):
        lat, lng, address = coordinates_for(uprn)
        dist = haversine_km(centre_lat, centre_lng, lat, lng)
        if dist <= radius_km:
            nearby.append({"uprn": uprn, "address": address, "distance_km": round(dist, 2)})

    nearby.sort(key=lambda x: x["distance_km"])

    print(f"{len(nearby)} properties within {radius_km}km:")
    for p in nearby[:5]:
        print(f"  {p['address']}: {p['distance_km']}km")

    return nearby


# Example: how far is each address at SW1A 1AA from 10 Downing Street?
results = distances_from(100023336956, "SW1A 1AA", radius_km=2.0)

Comparison: postcode vs UPRN geocoding

Feature Postcode centroid UPRN property-level
Accuracy ~100m (postcode centre) ~1m (building entrance)
Coverage 1.8M UK postcodes 29M+ properties
API calls 1 token (one call to /postcode-profile/) 7 tokens (addresses 2 + property 5)
Best for Area analysis, heatmaps, catchment areas Property mapping, valuation, surveying
Extra data Demographics, prices, schools EPC, property type, floor area, build year

API call costs

Geocoding calls are weighted in tokens (100 tokens = £1). A postcode centroid is 1 token. Geocoding to a specific property is the two calls above: 2 tokens for the addresses, 5 for the property, so 7 in total. A first £10 top-up is matched to 2,000 tokens: 2,000 centroids, or ~285 properties. A £250 top-up (25,000 tokens) covers ~3,571 geocodes, or 25,000 postcode centroids. A monthly subscription earns up to 75% bonus tokens.

  • GET /postcode-profile/?postcode={postcode}: weight 1 (postcode centroid plus area statistics)
  • GET /address/postcode/{postcode}/: weight 2 (returns all addresses at a postcode)
  • GET /property/{uprn}/address/: weight 5 (address fields and WGS84 coordinates)

Frequently asked questions

What is UK postcode geocoding?

Postcode geocoding converts a UK postcode (e.g. SW1A 1AA) into coordinates. Homedata does it two ways: /postcode-profile/ returns the postcode centroid in one call for 1 token, and for a coordinate that points at a specific building you resolve the postcode to its addresses and then take that property's coordinates: two calls, 7 tokens.

How accurate is postcode-level geocoding?

A postcode centroid is a single point for 15–100 addresses and can sit over 100 metres from the property you meant: fine for a heatmap, wrong for a map pin. UPRN-level geocoding gives coordinates for one specific building, to about a metre.

What's the difference between postcode and UPRN geocoding?

Postcode geocoding returns the centroid (centre point) of a postcode area, which may contain 15–100 addresses. UPRN geocoding returns the exact coordinates of a specific property. Homedata serves both: the centroid in one call for area work, the property in two for anything that points at a building.

Is there a free UK geocoding API?

There is no free tier: Homedata is pay as you go (100 tokens = £1, no subscription needed) and your first top-up is matched 100%, enough to test and prototype. A £10 top-up matched to 2,000 tokens covers 2,000 postcode centroids at 1 token each, or about 285 property-level geocodes at 7. No credit card required. Sign up and get your API key in 30 seconds.

Start building with a free API key →

Pay as you go · EPC, Land Registry, council tax, schools · First top-up matched 100%

Get an API key

Start geocoding UK postcodes

Get your API key and geocode your first postcode in under a minute. pay as you go, 100 tokens = £1, no subscription needed.

Contains OS data © Crown copyright and database right 2026. Contains Royal Mail data © Royal Mail copyright and database right 2026. Contains National Statistics data © Crown copyright and database right 2026. Licensed under the Open Government Licence v3.0.