# UK Postcode Lookup (`cliqtomedia/gb-postcode-lookup`) Actor

Bulk lookup of up to 1,000 Great Britain postcodes: latitude/longitude, eastings/northings, local authority, ward, constituency, LSOA/MSOA and GSS codes from the ONS Postcode Directory via Postcodes.io. Clear not-found, terminated and invalid statuses.

- **URL**: https://apify.com/cliqtomedia/gb-postcode-lookup.md
- **Developed by:** [Cliqto Media](https://apify.com/cliqtomedia) (community)
- **Categories:** Developer tools, Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $8.50 / 1,000 postcode results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

Paste a list of Great Britain postcodes. UK Postcode Lookup returns one row per postcode with latitude/longitude, OS grid eastings/northings, local authority, ward, parliamentary constituency, LSOA/MSOA and their official GSS codes from the ONS Postcode Directory (via [Postcodes.io](https://postcodes.io/)). No login or API key is needed.

It is for CRM, logistics, marketing and data teams who need to enrich a postcode column. A run can have just 1 postcode; the 5-postcode example takes a few seconds and costs about $0.05. The full price is paid only for postcodes found with coordinates (active or terminated); other answered rows cost a tiny check fee.

![UK Postcode Lookup: Great Britain postcodes in, coordinates, district, ward and LSOA codes out, with found, terminated and not found status](https://api.apify.com/v2/key-value-stores/bbV2ODw1VzCu6Vj9N/records/README-BANNER-20261012.png)

### Table of contents

- [What it does](#what-it-does)
- [Quick start](#quick-start)
- [Input](#input)
- [Output](#output)
- [Pricing and limits](#pricing-and-limits)
- [Use cases](#use-cases)
- [Scheduling and monitoring](#scheduling-and-monitoring)
- [API, export and integrations](#api-export-and-integrations)
- [Limits and partial results](#limits-and-partial-results)
- [Troubleshooting](#troubleshooting)
- [FAQ](#faq)
- [Data source and licence](#data-source-and-licence)
- [Support](#support)

### What it does

You add up to 1,000 postcodes. The Actor cleans each one (case, spaces), looks it up in the ONS Postcode Directory and saves one Dataset row for each input, in the same order. Every row has a `status`, so you always know why a row has no data.

| `status` | Meaning | Charged? |
| --- | --- | --- |
| `found` | Active postcode with coordinates and areas | Yes (if it has coordinates) |
| `terminated` | Old postcode, no longer used. Last known location and end date only | Yes (if it has coordinates) |
| `not_found` | Valid shape, but not in the directory | Check fee only |
| `invalid_format` | Not a full UK postcode (for example `SW1A` or `hello`). No lookup is made | Check fee only |
| `unsupported_licensed_region` | Northern Ireland (`BT…`). No lookup is made, see [limits](#limits-and-partial-results) | Check fee only |
| `error` | The source did not answer. This is not a "not found" answer | No |

Repeated postcodes are looked up once. The repeat row copies the data and has `duplicateOfIndex` set. It costs only the check fee.

#### Good fit

- Add map coordinates to a list of customer or store postcodes.
- Join postcodes to ONS statistics with LSOA, MSOA, output area and district GSS codes.
- Group leads by local authority, region or parliamentary constituency.
- Find typos and old postcodes in a CRM export.

#### Not a fit / limits

- No Northern Ireland (BT) data. Its licence does not allow free commercial reuse.
- No street addresses or house numbers. Coordinates are the postcode centre, not a building.
- No reverse geocoding (coordinates → postcode) and no autocomplete.
- Isle of Man (IM) and Channel Islands (GY, JE) postcodes return `found` but without coordinates or census areas. Some old IM postcodes are `terminated` without coordinates.

### Quick start

1. Open the **Input** tab.
2. Keep the 5 example postcodes in **Postcodes** (`postcodes`) or paste your own, one per line.
3. Keep **Check terminated postcodes** (`includeTerminated`) on.
4. Click **Start**.
5. Open the **Overview** table in the Output tab and check `status`, `latitude` and `adminDistrict`.

Expected result: 5 `found` rows in about 3 seconds, for example `SW1A 2AA` → Westminster, 51.503541, -0.12767.

### Input

#### Input fields

| Field | Plain name | Type | Required | Default / prefill | Allowed values | Effect | Recommendation |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `postcodes` | Postcodes | array of strings | yes | prefill: `SW1A 2AA`, `EH99 1SP`, `CF99 1SN`, `M60 2LA`, `B1 1BB` | 1–1,000 items | The postcodes to look up. Case and spaces do not matter (`sw1a2aa` works). Empty lines are skipped. Items longer than 40 characters are cut and marked `invalid_format` | Split lists over 1,000 into several runs |
| `includeTerminated` | Check terminated postcodes | boolean | no | `true` | `true`, `false` | When a postcode is not active, also check the list of old (terminated) postcodes. Adds one small request per unknown postcode | Keep `true` for old CRM data. Use `false` if you only want active postcodes |

An empty list, more than 1,000 items or an unknown field stops the run before any lookup.

#### Input examples

##### Enrich a short list

```json
{
  "postcodes": ["SW1A 2AA", "EH99 1SP", "CF99 1SN", "M60 2LA", "B1 1BB"]
}
```

Expected output: 5 `found` rows with coordinates, district, ward, constituency and LSOA/MSOA. Cost: 5 results + 1 start.

##### Clean a messy CRM column

```json
{
  "postcodes": ["sw1a2aa", "AB1 0AA", "ZZ1 1ZZ", "not a postcode", "BT1 5GS", "SW1A 2AA"],
  "includeTerminated": true
}
```

Expected output: `found`, `terminated` (since 1996-06), `not_found`, `invalid_format`, `unsupported_licensed_region`, and a duplicate of row 0. Cost: 2 results + 4 checks + 1 start.

### Output

One Dataset item per input item. All fields are always present; `null` means "no data", never zero.

#### Output example

```json
{
  "inputIndex": 0,
  "input": "SW1A 2AA",
  "postcode": "SW1A 2AA",
  "status": "found",
  "statusReason": "Active postcode",
  "errorCode": null,
  "duplicateOfIndex": null,
  "latitude": 51.503541,
  "longitude": -0.12767,
  "eastings": 530047,
  "northings": 179951,
  "positionalQuality": 1,
  "country": "England",
  "region": "London",
  "adminDistrict": "Westminster",
  "adminDistrictCode": "E09000033",
  "adminWard": "St James's",
  "adminWardCode": "E05013806",
  "parliamentaryConstituency": "Cities of London and Westminster",
  "parliamentaryConstituencyCode": "E14001172",
  "lsoa": "Westminster 018C",
  "lsoaCode": "E01004736",
  "msoa": "Westminster 018",
  "msoaCode": "E02000977",
  "introducedOn": "1980-01",
  "terminatedOn": null,
  "provider": "Postcodes.io (ONS Postcode Directory)",
  "fetchedAt": "2026-10-12T07:46:24.376Z"
}
```

This example is trimmed: the real row also has `outcode`, `incode`, county, parish, output area, built-up area, police, NHS and rural-urban fields.

#### Output fields

| Field | Meaning | Type | Can be empty? | Example |
| --- | --- | --- | --- | --- |
| `inputIndex` | Position in your input list (starts at 0) | integer | no | `0` |
| `input` | Your value, with outer spaces removed | string | no | `sw1a2aa` |
| `postcode` | Normalized postcode | string | yes (invalid input) | `SW1A 2AA` |
| `status` | Row result, see the table above | string | no | `found` |
| `statusReason` | Plain-English reason | string | no | `Active postcode` |
| `errorCode` | Technical code, only for `error` rows | string | yes | `SOURCE_UNAVAILABLE` |
| `duplicateOfIndex` | For a repeat: `inputIndex` of the first copy | integer | yes | `0` |
| `latitude`, `longitude` | WGS84 centre of the postcode | number | yes | `51.503541`, `-0.12767` |
| `eastings`, `northings` | OS National Grid reference | integer | yes | `530047`, `179951` |
| `positionalQuality` | ONSPD accuracy, 1 (best) to 9 (no grid reference) | integer | yes | `1` |
| `outcode`, `incode` | The two parts of the postcode | string | yes | `SW1A`, `2AA` |
| `country`, `region` | Nation and English region | string | yes | `England`, `London` |
| `adminDistrict`, `adminDistrictCode` | Local authority and GSS code | string | yes | `Westminster`, `E09000033` |
| `adminCounty`, `adminCountyCode` | County (only in two-tier areas) | string | yes | `null` |
| `adminWard`, `adminWardCode` | Electoral ward | string | yes | `St James's`, `E05013806` |
| `parish`, `parishCode` | Parish or unparished area | string | yes | `Westminster, unparished area` |
| `parliamentaryConstituency`, `parliamentaryConstituencyCode` | Westminster constituency (2024 boundaries) | string | yes | `Cities of London and Westminster`, `E14001172` |
| `lsoa`, `lsoaCode` | Census 2021 Lower Layer Super Output Area | string | yes | `Westminster 018C`, `E01004736` |
| `msoa`, `msoaCode` | Census 2021 Middle Layer Super Output Area | string | yes | `Westminster 018`, `E02000977` |
| `outputArea` | Census 2021 output area code | string | yes | `E00023936` |
| `builtUpArea` | Built-up area | string | yes | `City of Westminster` |
| `policeForceArea` | Police force | string | yes | `Metropolitan Police` |
| `nhsIcb` | NHS Integrated Care Board | string | yes | `NHS West and North London Integrated Care Board` |
| `ruralUrban2021` | Rural-urban classification 2021 | string | yes | `Urban: Nearer to a major town or city` |
| `introducedOn` | Month the postcode started (YYYY-MM) | string | yes | `1980-01` |
| `terminatedOn` | Month it stopped (YYYY-MM), for terminated postcodes | string | yes | `1996-06` |
| `provider` | Data provider | string | no | `Postcodes.io (ONS Postcode Directory)` |
| `sourceUrl` | Source endpoint used for this row | string | yes | `https://api.postcodes.io/postcodes` |
| `fetchedAt` | When the row was fetched (UTC) | string | no | `2026-10-12T07:46:24.376Z` |

GSS "not applicable" codes (like `E99999999` or `M99999999`) in code fields and `outputArea` are returned as `null`. A `terminated` row has only coordinates and `terminatedOn`: the directory keeps no area data for old postcodes.

#### Output surfaces

| Situation | Where | What you see |
| --- | --- | --- |
| Normal run | Dataset views **Overview**, **Geography**, **Status details** | One row per input |
| Nothing found | Dataset + `RUN_SUMMARY` | Rows with `not_found` / `invalid_format`, run status `NO_RESULTS`, run succeeds |
| Source problem | Dataset + `RUN_SUMMARY` | Unfinished rows have `status: error`; summary `PARTIAL` or `ERROR` |
| Stopped early | Dataset + `RUN_SUMMARY` | Remaining rows have `status: error` with `DEADLINE_REACHED` or `MAX_CHARGE_REACHED`; summary `CAPPED` |
| Summary | Key-value store record `RUN_SUMMARY` | `status`, `message`, counts per status, charged results, data licence and attribution |

### Pricing and limits

#### Pricing unit

This Actor uses pay-per-event pricing. Platform usage is included: you pay only for these events.

| Event | When | Free plan | Bronze | Silver | Gold+ |
| --- | --- | ---: | ---: | ---: | ---: |
| Postcode result | One unique `found` or `terminated` postcode with coordinates | $0.01 | $0.0095 | $0.009 | $0.0085 |
| Postcode checked | Any other answered row: `not_found`, `invalid_format`, duplicate, Northern Ireland, or found without coordinates | $0.00005 | $0.00005 | $0.00005 | $0.00005 |
| Actor start | Once per run (default 256 MB memory), also when the run fails | $0.002 | $0.002 | $0.002 | $0.002 |

Free: `error` rows (source problem, time limit or cost cap). A run of 1,000 unknown postcodes costs $0.052.

#### Cost examples (Free plan prices)

| Scenario | Input | Charged results | Cost |
| --- | --- | ---: | ---: |
| Prefill | 5 postcodes | 5 | $0.052 |
| Typical list | 100 postcodes, 96 found | 96 + 4 checks | $0.962 |
| Full run | 1,000 postcodes, 951 unique found | 951 + 49 checks | $9.514 |
| Nothing found | 3 unknown or invalid postcodes | 0 + 3 checks | $0.002 |
| Many unknown | 1,000 unknown postcodes | 0 + 1,000 checks | $0.052 |

Set **Maximum cost per run** in the run options to cap spending. When the cap is reached, the Actor stops charging, marks the remaining rows `error` with `MAX_CHARGE_REACHED` and sets the summary to `CAPPED`.

#### Limits

- Up to 1,000 postcodes per run. A 1,000-postcode run took about 5 seconds in our tests.
- Internal time limit for lookups: 240 seconds.
- With `includeTerminated: true`, every unknown postcode needs one extra request (about 0.25 s each). A list with many hundreds of unknown postcodes can reach the 240-second limit: the remaining rows are `error` with `DEADLINE_REACHED` (free) and the summary is `CAPPED`.

### Use cases

| Buyer job | Input | Key fields | Next step |
| --- | --- | --- | --- |
| Map customers | `postcodes` from a CRM column | `latitude`, `longitude`, `status` | Map tool, BI dashboard |
| Join to ONS statistics | `postcodes` | `lsoaCode`, `msoaCode`, `outputArea`, `adminDistrictCode` | ONS / Census tables |
| Sales territories | `postcodes` | `region`, `adminDistrict`, `adminCounty`, `parliamentaryConstituency` | CRM fields |
| Data cleaning | `postcodes` | `status`, `postcode` (normalized), `terminatedOn` | Fix or remove bad records |

### Scheduling and monitoring

- The ONS Postcode Directory is updated about every three months. To keep a list fresh, schedule a run after each update and compare `status` and `terminatedOn` with the last run.
- Watch `RUN_SUMMARY.status`: `COMPLETE` and `NO_RESULTS` are normal, `PARTIAL` and `ERROR` mean the source had a problem.

### API, export and integrations

- **API:** start a run with `POST https://api.apify.com/v2/acts/cliqtomedia~gb-postcode-lookup/runs` and the input JSON, then read the default Dataset. See the **API** tab for ready code.
- **Export:** download the Dataset as CSV, Excel, JSON or XML from the Output tab.
- **No-code:** connect the Actor in Make, Zapier or n8n with the Apify integration.

#### Use with AI agents (MCP)

- MCP server with only this Actor: `https://mcp.apify.com?tools=cliqtomedia/gb-postcode-lookup`
- Smallest useful input: `{"postcodes": ["SW1A 2AA"]}`
- Key output fields: `status` (found / terminated / not_found / invalid_format / unsupported_licensed_region / error), `latitude`, `longitude`, `adminDistrict`, `lsoaCode`.
- A run with up to 100 postcodes usually finishes in under 10 seconds. Cost: $0.002 per run plus $0.0085–$0.01 per found postcode and $0.00005 per other answered row. Up to 1,000 postcodes per run; Northern Ireland is not supported.

### Limits and partial results

- **Northern Ireland (BT):** NI postcode data needs a commercial licence from Land & Property Services. These rows are marked `unsupported_licensed_region` before any lookup and cost only the check fee.
- **Freshness:** data follows the ONS Postcode Directory loaded by Postcodes.io (ONSPD August 2026 when we checked on 2026-10-12). It is updated quarterly. Very new postcodes may be `not_found` until the next update.
- **Source problems:** requests have a 20-second timeout and up to 3 tries. If the source stops answering, the rest of the rows are saved as `error` (not `not_found`) and are not charged. The summary says `PARTIAL` (some results) or `ERROR` (none, the run fails).

### Troubleshooting

#### Many rows are `invalid_format`

Symptom: rows like `SW1A` or `SW1A-2AA` are not looked up.
Reason: the Actor needs a full postcode (outward and inward part). Letters, digits and spaces only.
Action: fix the column in your source, for example remove dashes, and run again.

#### A postcode I know is real is `not_found`

Reason: it may be very new (not yet in the quarterly directory) or mistyped.
Action: check `statusReason`, try `includeTerminated: true`, or check again after the next ONSPD update.

#### Rows have `status: error`

Reason: Postcodes.io did not answer in time or limited requests. These rows are free.
Action: open `RUN_SUMMARY` for `errorCode`, then run the same input again later.

### FAQ

#### Do I need an API key or account for Postcodes.io?

No. The Actor uses the public Postcodes.io API without a key.

#### Why do terminated postcodes have no district or ward?

The directory keeps only the last location and end date for old postcodes. Use `latitude`/`longitude` if you need an area.

#### Am I charged for duplicates or errors?

`error` rows are free. Duplicates and other rows without a paid result cost $0.00005 each. The full price is charged only for the first copy of a found or terminated postcode with coordinates, plus $0.002 per run.

#### Is the data complete?

It covers active and terminated postcodes in England, Scotland and Wales from the ONS Postcode Directory. Isle of Man and Channel Islands postcodes are listed but have no coordinates. Coordinates are postcode centres. Some areas, like county or parish, are empty where they do not apply.

### Data source and licence

Data: [ONS Postcode Directory](https://www.ons.gov.uk/methodology/geography/geographicalproducts/postcodeproducts) via [Postcodes.io](https://postcodes.io/docs/licences/). Great Britain postcode data may be used under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/) and OS OpenData terms. Attribution (also in `RUN_SUMMARY`):

Contains Ordnance Survey data © Crown copyright and database right 2025. Contains Royal Mail data © Royal Mail copyright and database right 2025. Contains National Statistics data © Crown copyright and database right 2025. Contains NRS data © Crown copyright and database right 2025. Source: Office for National Statistics licensed under the Open Government Licence v3.0.

This Actor is not affiliated with ONS, Ordnance Survey, Royal Mail or Postcodes.io.

### Support

For bugs, questions or feature requests, open an issue in the **Issues** tab. Include the run ID and the postcodes that look wrong. Do not send private customer data.

# Changelog

This Actor's version history is a separate document: https://apify.com/cliqtomedia/gb-postcode-lookup/changelog.md

# Actor input Schema

## `postcodes` (type: `array`):

Up to 1,000 UK postcodes, one per line, e.g. SW1A 2AA. Case and spaces don't matter (sw1a2aa works). Duplicates are looked up once; repeats cost only the small check fee. Northern Ireland postcodes (starting with BT) are returned as unsupported_licensed_region without a lookup.

## `includeTerminated` (type: `boolean`):

When a postcode is not active, check whether it is a terminated (retired) postcode and return its last known location and termination date. Turn off to report such postcodes simply as not_found.

## Actor input object example

```json
{
  "postcodes": [
    "SW1A 2AA",
    "EH99 1SP",
    "CF99 1SN",
    "M60 2LA",
    "B1 1BB"
  ],
  "includeTerminated": true
}
```

# Actor output Schema

## `overview` (type: `string`):

All rows: status, coordinates and main geography.

## `geography` (type: `string`):

GSS codes, LSOA/MSOA and census output areas.

## `statusDetails` (type: `string`):

Rows that are not found, terminated, invalid, Northern Ireland, duplicates or errors, with the reason.

## `runSummary` (type: `string`):

Status, counts, charged rows, source attribution and request statistics.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "postcodes": [
        "SW1A 2AA",
        "EH99 1SP",
        "CF99 1SN",
        "M60 2LA",
        "B1 1BB"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("cliqtomedia/gb-postcode-lookup").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "postcodes": [
        "SW1A 2AA",
        "EH99 1SP",
        "CF99 1SN",
        "M60 2LA",
        "B1 1BB",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("cliqtomedia/gb-postcode-lookup").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "postcodes": [
    "SW1A 2AA",
    "EH99 1SP",
    "CF99 1SN",
    "M60 2LA",
    "B1 1BB"
  ]
}' |
apify call cliqtomedia/gb-postcode-lookup --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,cliqtomedia/gb-postcode-lookup"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/MHuVzzXnytc6LsSO4/builds/wsvABoqczyYhbolIy/openapi.json
