PHPSGC API

PSGC API reference

A read-only JSON API over the Philippine Statistics Authority's Philippine Standard Geographic Code: 18 regions, 82 provinces, 149 cities, 1,493 municipalities, the 14 sub-municipalities of Manila, and 42,010 barangays (3Q 2026). No API key needed.

Base URL
https://psgc.billyjamesramos.com/api/v1

Conventions

  • Codes are 10-digit strings (RR PPP MM BBB: region, province, city/municipality, barangay). Always treat them as strings; they have leading zeros.
  • Hierarchy quirks: NCR has no provinces; Highly Urbanized Cities (HUCs), the City of Isabela and the BARMM Special Geographic Area are not under a province (provinceCode: null, use province=none). geographicProvinceCode tells you which province such a city sits in on the map. Manila's barangays also carry a subMunicipalityCode.
  • Lists return { data: [...], meta: { total, page, limit, totalPages, hasNextPage } }. Single items return { data: {...} }.
  • Errors always look like { error: { status, code, message } } with codes BAD_REQUEST, NOT_FOUND, SERVICE_UNAVAILABLE, INTERNAL_ERROR.
  • CORS: Access-Control-Allow-Origin: *, so you can call it straight from a browser.
  • Caching: Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800 (meta: 5 minutes). Every response has X-PSGC-Version and X-PSGC-Release headers.
Error format
HTTP/1.1 400 Bad Request
{ "error": { "status": 400, "code": "BAD_REQUEST", "message": "limit must be an integer between 1 and 500." } }

HTTP/1.1 404 Not Found
{ "error": { "status": 404, "code": "NOT_FOUND", "message": "Barangay 1380100003 not found." } }

Endpoints

GET/api/v1/meta

Current release: version, PSA release name/date, counts, when it went live. Poll this to detect quarterly updates.

Response
{
  "data": {
    "version": "2026-Q3",
    "releaseId": "2026-Q3-20261021T071052Z",
    "releaseName": "Third Quarter 2026 PSGC Updates: Correction of the Name of One Barangay",
    "releaseDate": "2026-10-12",
    "referenceNo": "2026-331",
    "asOf": "30 September 2026",
    "sourceFile": "https://psa.gov.ph/system/files/scd/PSGC-3Q-2026-Publication-Datafile.xlsx",
    "counts": { "regions": 18, "provinces": 82, "cities": 149, "highlyUrbanizedCities": 33,
                "independentComponentCities": 5, "componentCities": 111, "municipalities": 1493,
                "subMunicipalities": 14, "barangays": 42010 },
    "totalsMatch": true,
    "promotedAt": "2026-10-21T07:10:54.404Z",
    "...": "importedAt, notes, attribution, psaPublishedTotals"
  }
}
GET/api/v1/regions

All 18 regions with roll-up counts.

ParamInDescription
pagequeryPage number, starting at 1 (default 1).
limitquery1-100 (default 100).
Response
{
  "data": [{ "code": "0100000000", "name": "Region I (Ilocos Region)", "correspondenceCode": "010000000",
             "population": 5342453, "provinceCount": 4, "cityCount": 9, "municipalityCount": 116,
             "subMunicipalityCount": 0, "barangayCount": 3267 }],
  "meta": { "total": 18, "page": 1, "limit": 1, "totalPages": 18, "hasNextPage": true }
}
GET/api/v1/regions/{code}

One region.

ParamInDescription
codepath10-digit region code, e.g. 1300000000 (NCR).
Response
{ "data": { "code": "1300000000", "name": "National Capital Region (NCR)", "provinceCount": 0, "cityCount": 16, "municipalityCount": 1, "barangayCount": 1715, "...": "..." } }
GET/api/v1/regions/{code}/provinces

Provinces in a region. NCR returns an empty list (it has no provinces).

ParamInDescription
codepathRegion code.
Response
{ "data": [{ "code": "1400100000", "name": "Abra", "regionCode": "1400000000", "incomeClass": "1st", "population": 246948, "cityCount": 0, "municipalityCount": 27, "barangayCount": 303, "...": "..." }], "meta": { "total": 6, "page": 1, "limit": 100, "totalPages": 1, "hasNextPage": false, "regionCode": "1400000000" } }
GET/api/v1/regions/{code}/cities-municipalities

Cities/municipalities in a region. Use province=none for LGUs outside any province (HUCs, NCR, City of Isabela, BARMM Special Geographic Area).

ParamInDescription
codepathRegion code.
provincequeryProvince code, or none.
Response
{ "data": [{ "code": "0730600000", "name": "City of Cebu", "type": "City", "cityClass": "HUC", "provinceCode": null, "geographicProvinceCode": "0702200000", "...": "..." }, "..."], "meta": { "total": 3, "...": "..." } }
GET/api/v1/provinces

All 82 provinces.

ParamInDescription
regionqueryFilter by region code.
pagequeryPage number, starting at 1 (default 1).
limitquery1-100 (default 100).
Response
{ "data": [{ "code": "1400100000", "name": "Abra", "...": "..." }], "meta": { "total": 6, "...": "..." } }
GET/api/v1/provinces/{code}

One province.

ParamInDescription
codepath10-digit province code.
Response
{ "data": { "code": "0102800000", "name": "Ilocos Norte", "regionCode": "0100000000", "...": "..." } }
GET/api/v1/provinces/{code}/cities-municipalities

Component cities and municipalities of a province (HUCs are independent and are not listed under a province).

ParamInDescription
codepathProvince code.
pagequeryPage number, starting at 1 (default 1).
limitquery1-500 (default 500).
Response
{ "data": [{ "code": "0102801000", "name": "Adams", "type": "Mun", "...": "..." }, "..."], "meta": { "total": 23, "...": "..." } }
GET/api/v1/cities-municipalities

All 1,642 cities and municipalities, filterable.

ParamInDescription
regionqueryRegion code.
provincequeryProvince code, or none.
typequerycity, municipality, huc, icc or cc.
namequeryName contains (accent-insensitive).
pagequeryPage number, starting at 1 (default 1).
limitquery1-2000 (default 2000).
Response
{
  "data": [{
    "code": "0102812000", "name": "City of Laoag", "type": "City", "cityClass": "CC", "incomeClass": "3rd",
    "regionCode": "0100000000", "provinceCode": "0102800000", "geographicProvinceCode": "0102800000",
    "specialAreaCode": null, "isCapital": true, "correspondenceCode": "012812000", "oldNames": null,
    "population": 112117, "subMunicipalityCount": 0, "barangayCount": 80
  }, "..."],
  "meta": { "total": 2, "page": 1, "limit": 2000, "totalPages": 1, "hasNextPage": false }
}
GET/api/v1/cities-municipalities/{code}

One city or municipality.

ParamInDescription
codepath10-digit code.
Response
{ "data": { "code": "1381300000", "name": "Quezon City", "type": "City", "cityClass": "HUC", "...": "..." } }
GET/api/v1/cities-municipalities/{code}/barangays

Barangays of a city/municipality. Limit up to 1000 so even Manila (897) fits in one call.

ParamInDescription
codepathCity/municipality code.
subMunicipalityqueryManila only: filter by sub-municipality code.
pagequeryPage number, starting at 1 (default 1).
limitquery1-1000 (default 1000).
Response
{ "data": [{ "code": "0102812001", "name": "Bgy. No. 1, San Lorenzo (Pob.)", "urbanRural": "U", "isPoblacion": true, "population": 1250, "...": "..." }, "..."], "meta": { "total": 80, "...": "..." } }
GET/api/v1/cities-municipalities/{code}/sub-municipalities

Sub-municipalities (districts) of the City of Manila.

ParamInDescription
codepath1380600000 (Manila).
Response
{ "data": [{ "code": "1380601000", "name": "Tondo I/II", "cityMunicipalityCode": "1380600000", "population": 637942, "barangayCount": 259 }, "..."], "meta": { "total": 14, "...": "..." } }
GET/api/v1/barangays

All 42,010 barangays, filterable and paginated.

ParamInDescription
regionqueryRegion code.
provincequeryProvince code, or none.
cityMunicipalityqueryCity/municipality code (alias: city).
subMunicipalityquerySub-municipality code.
urbanRuralqueryU or R (2024 CPH).
namequeryName contains.
pagequeryPage number, starting at 1 (default 1).
limitquery1-500 (default 100).
Response
{
  "data": [{
    "code": "1380601003", "name": "Barangay 3", "regionCode": "1300000000", "provinceCode": null,
    "cityMunicipalityCode": "1380600000", "subMunicipalityCode": "1380601000", "urbanRural": "U",
    "isPoblacion": false, "correspondenceCode": "133901003", "oldNames": null, "population": 1232
  }],
  "meta": { "total": 897, "page": 3, "limit": 1, "totalPages": 897, "hasNextPage": true }
}
GET/api/v1/barangays/{code}

One barangay.

ParamInDescription
codepath10-digit barangay code.
Response
{ "data": { "code": "0301413033", "name": "Friendship Village Resources", "oldNames": "FVR", "urbanRural": "U", "population": 14296, "...": "..." } }
GET/api/v1/codes/{code}

Look up any code (any level) with its full ancestry. Also accepts the older 9-digit correspondence code.

ParamInDescription
codepath10-digit PSGC code or 9-digit correspondence code.
Response
{
  "data": {
    "code": "1380100001", "name": "Barangay 1", "regionCode": "1300000000", "provinceCode": null,
    "cityMunicipalityCode": "1380100000", "subMunicipalityCode": null, "urbanRural": "U",
    "isPoblacion": false, "correspondenceCode": "137501001", "oldNames": null, "population": 2356,
    "level": "barangay"
  },
  "ancestry": {
    "region": { "code": "1300000000", "name": "National Capital Region (NCR)" },
    "province": null,
    "cityMunicipality": { "code": "1380100000", "name": "City of Caloocan", "type": "City", "cityClass": "HUC" },
    "subMunicipality": null
  },
  "fullName": "Barangay 1, City of Caloocan, National Capital Region (NCR)"
}

Copy-paste examples

fetch
// JavaScript (browser or Node 18+)
const BASE = "https://psgc.billyjamesramos.com";

const res = await fetch(`${BASE}/api/v1/codes/1380100001`);
if (!res.ok) throw new Error((await res.json()).error.message);
const { data, ancestry, fullName } = await res.json();
console.log(fullName); // "Barangay 1, City of Caloocan, National Capital Region (NCR)"

// Paginate through all barangays of a province
let page = 1, all = [];
while (true) {
  const r = await fetch(`${BASE}/api/v1/barangays?province=0102800000&limit=500&page=${page}`).then(r => r.json());
  all.push(...r.data);
  if (!r.meta.hasNextPage) break;
  page++;
}

Integration plan: using this API in another app

  1. Configure the base URL as an environment variable so you can switch between this hosted API, a staging copy, or your own self-hosted instance without code changes.
    .env.local
    # .env.local in YOUR app
    NEXT_PUBLIC_PSGC_API_BASE_URL=https://psgc.billyjamesramos.com
  2. Build the address form as cascading dropdowns: Region → Province → City/Municipality → Barangay. Handle the two special cases: when a region has no provinces (NCR) go straight to its cities, and offer an “Independent city (HUC) / other” choice that loads ?province=none.
    AddressPicker.tsx (React)
    "use client";
    // Cascading address picker: Region -> Province -> City/Municipality -> Barangay.
    // Stores PSGC CODES (stable), shows names (which PSA can correct).
    import { useEffect, useState } from "react";
    
    const BASE = process.env.NEXT_PUBLIC_PSGC_API_BASE_URL;
    type Opt = { code: string; name: string };
    const get = (path: string): Promise<Opt[]> => fetch(BASE + path).then((r) => r.json()).then((j) => j.data);
    
    export function AddressPicker({ onChange }: { onChange: (v: Record<string, string | null>) => void }) {
      const [regions, setRegions] = useState<Opt[]>([]);
      const [provinces, setProvinces] = useState<Opt[]>([]);
      const [cities, setCities] = useState<Opt[]>([]);
      const [barangays, setBarangays] = useState<Opt[]>([]);
      const [independent, setIndependent] = useState<Opt[]>([]);
      const [v, setV] = useState({ region: "", province: "", city: "", barangay: "" });
    
      useEffect(() => { get("/api/v1/regions").then(setRegions); }, []);
    
      async function pickRegion(region: string) {
        setV({ region, province: "", city: "", barangay: "" }); setCities([]); setBarangays([]);
        const [p, independent] = await Promise.all([
          get(`/api/v1/regions/${region}/provinces`),
          // HUCs / NCR cities / special areas are not under a province:
          get(`/api/v1/regions/${region}/cities-municipalities?province=none`),
        ]);
        setProvinces(p);
        setIndependent(independent);
        setCities(p.length === 0 ? independent : []); // NCR: skip straight to cities
      }
      async function pickProvince(province: string) {
        setV((s) => ({ ...s, province, city: "", barangay: "" })); setBarangays([]);
        setCities(province === "none" ? independent : await get(`/api/v1/provinces/${province}/cities-municipalities`));
      }
      async function pickCity(city: string) {
        setV((s) => ({ ...s, city, barangay: "" }));
        setBarangays(await get(`/api/v1/cities-municipalities/${city}/barangays?limit=1000`));
      }
      function pickBarangay(barangay: string) {
        const next = { ...v, barangay };
        setV(next);
        onChange({ regionCode: next.region, provinceCode: next.province || null, cityMunicipalityCode: next.city, barangayCode: barangay });
      }
    
      const sel = (value: string, opts: Opt[], on: (c: string) => void, label: string, extra?: Opt) => (
        <select value={value} onChange={(e) => on(e.target.value)} disabled={!opts.length && !extra} aria-label={label}>
          <option value="">{label}</option>
          {opts.map((o) => <option key={o.code} value={o.code}>{o.name}</option>)}
          {extra && <option value={extra.code}>{extra.name}</option>}
        </select>
      );
      return (
        <div style={{ display: "grid", gap: 8 }}>
          {sel(v.region, regions, pickRegion, "Region")}
          {sel(v.province, provinces, pickProvince, "Province", provinces.length ? { code: "none", name: "Independent city (HUC) / other" } : undefined)}
          {sel(v.city, cities, pickCity, "City / Municipality")}
          {sel(v.barangay, barangays, pickBarangay, "Barangay")}
        </div>
      );
    }
  3. Store PSGC codes, not names. Save regionCode, provinceCode, cityMunicipalityCode, barangayCode (as strings) plus the version you validated against. Names get corrected by PSA (the 3Q 2026 release itself is a barangay name correction); codes stay stable. Render names at display time, or cache them with the version.
  4. Handle quarterly updates with a version check. PSA publishes PSGC updates quarterly. Call /api/v1/meta on app start or daily; when version changes, drop cached lists and re-validate stored codes via /api/v1/codes/{code} (a 404 means the code was dissolved or merged).
    version check
    // Check once a day (or on app start) whether PSGC data changed
    const KEY = "psgc-version";
    const meta = await fetch(`${BASE}/api/v1/meta`).then((r) => r.json());
    if (localStorage.getItem(KEY) !== meta.data.version) {
      // new quarterly release: clear cached dropdown lists and re-validate stored codes
      caches?.delete?.("psgc");
      localStorage.setItem(KEY, meta.data.version);
    }
    // Server side: re-check stored codes with GET /api/v1/codes/{code}; a 404 means the code was
    // removed/merged in the new release. Store the version alongside each saved address.
  5. Cache aggressively; be gentle. Lists change at most once a quarter. Cache region/province lists for the app lifetime and barangay lists per city for at least a day (respect Cache-Control). Debounce search-as-you-type (200-300 ms, min 2 chars). Paginate /barangays with limit=500 rather than hammering. There is no hard rate limit, but abusive traffic may be throttled at the edge.
  6. Server-side validation: before saving an address, verify the chain with /api/v1/codes/{barangayCode} and compare ancestry to the submitted parent codes.
  7. Self-host when you need guarantees (offline, SLA, data residency): deploy this repo with your own Postgres databases, see Self-hosting.

How data updates work (staging → review → main)

New PSA files never go straight to the public API. Every import lands in a separate staging database, is checked by sanity guards and diffed against main, and only a human approval on the signed review page promotes it. The previous main is kept so a rollback is one command.

flowchart TD
  PSA["psa.gov.ph PSGC page"] -->|"weekly check (browser updater; GitHub Action as backup)"| FILE["New Publication Datafile (.xlsx)"]
  FILE --> STAGE["npm run stage -- file.xlsx<br/>parse + verify vs PSA National Summary<br/>writes STAGING database only"]
  STAGE --> GUARDS{"Sanity guards<br/>count drop > 1%, malformed codes,<br/>truncated file, totals mismatch"}
  GUARDS --> DIFF["npm run diff<br/>staging vs main<br/>changes/*.md + *.json + signed review URL"]
  DIFF --> EMAIL["Email to owner<br/>change summary + review link"]
  EMAIL --> REVIEW["/admin/review<br/>signed HMAC link or ADMIN_SECRET"]
  REVIEW -->|Approve| PROMOTE["Promote in ONE transaction on MAIN<br/>main -> previous snapshot, staging -> main, audit row"]
  REVIEW -->|Reject| DISCARD["Discard staged release"]
  PROMOTE --> API["Public API /api/v1/*<br/>reads MAIN only"]
  PROMOTE -.->|"npm run rollback"| PREV[("Previous snapshot")]
  PREV -.-> API
  • Sanity guards flag any level whose count drops by more than 1%, more than 0.5% of codes removed, malformed or orphan codes, truncated files (below plausible national minimums) and mismatches against PSA's own National Summary sheet. Critical warnings must be explicitly acknowledged to approve.
  • Review links are HMAC-signed with ADMIN_SECRET, tied to one staged release id, expire (14 days by default) and stop working once that release is approved, rejected or superseded. Approval is a POST with a confirmation checkbox, so link scanners in mail clients can't trigger it.
  • Promotion runs in one Postgres transaction on main: current data → areas_previous, staged rows → areas, release_current updated, an audit_log row written. npm run rollback -- --yes swaps current and previous back.

Self-hosting

shell
git clone <this repo> && cd psgc-api && npm ci
cp .env.example .env.local   # set DATABASE_URL_MAIN, DATABASE_URL_STAGING, ADMIN_SECRET, PUBLIC_BASE_URL
npm run db:seed -- seed/PSGC-3Q-2026-Publication-Datafile.xlsx \
  --release-name "Third Quarter 2026 PSGC Updates: Correction of the Name of One Barangay" \
  --release-date 2026-10-12 --reference-no 2026-331
npm run build && npm start

Data attribution: Philippine Statistics Authority (PSA). This project is not affiliated with PSA.