achilleskal/greek-ota

Greek administrative divisions with stable identifiers — regions, regional units, municipalities, municipal units, communities and settlements, extracted from ELSTAT's official registers with full provenance and change history.

Maintainers

Package info

github.com/AchillesKal/greek-ota

Documentation

pkg:composer/achilleskal/greek-ota

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-20 20:21 UTC

This package is auto-updated.

Last update: 2026-08-20 20:40:11 UTC


README

CI Packagist PHP Code: MIT Data: ELSTAT terms

If you stored Greek municipality codes before 2021, some of them now point at a different municipality.

Between the 2020 and 2021 ELSTAT registers, 28 municipalities were silently re-coded. No reform, no announcement — ELSTAT re-sorted each regional unit alphabetically and the codes moved with it. The government's own change log, SMD02 «Διοικητικές Μεταβολές», reports «Καμμία μεταβολή» — no change — for exactly that period. The only official record of it is a single column in a single edition of a single spreadsheet.

Code 1402, in the Κοζάνη regional unit:

Register edition 1402 resolves to
2012 – 2020 Δήμος Βοΐου
2021 onward Δήμος Βελβεντού

Nothing happened to Βοΐου. Δήμος Βελβεντού was created in 2019 by splitting Σερβίων-Βελβεντού, and when the regional unit was re-sorted it took 1402 while Βοΐου moved to 1403. A row that joined on 1402 in 2020 and re-joined in 2021 is now attached to a different place — not a missing row, a wrong one, which no foreign key will catch.

This project publishes that buried column. It records 2,706 re-coded entities across all tiers — 28 municipalities, 101 municipal units, 737 communities, 1,840 settlements — of which 2,701 resolve to a known entity and are published here as dated intervals you can query. The other five cite codes the 2020 register does not contain; they are documented rather than guessed at (D6). That, plus every other change from 2012 to 2026, typed against ELSTAT's own records, with a stable identifier per entity that never moves.

The damage is not hypothetical, and it is live

That re-coding is reconstructed from a column ELSTAT published once and never mentioned, so the fair question is whether it ever hurt anybody. scripts/crosscheck.php asks a source that is not ELSTAT: Wikidata, which carries ELSTAT codes as property P1116 on about 21,000 Greek items and was not derived from this dataset.

1,335 of those codes are currently served with the name that code meant before the 2021 re-sequencing — 26 municipalities, 5 municipal units, 337 communities and 967 settlements. It says 1402 is Δήμος Βοΐου, 3203 is Βόρειας Κέρκυρας, 5204 is Αίγινας. Every one of those was the right answer in the 2020 edition, and every one of them is a wrong join today.

So the cost of a silent re-coding is not a thought experiment. It has already been paid, in one of the most widely reused reference datasets on the internet, by people who did nothing wrong except store a code. Dated intervals are what stop that; the rest of the corroboration — OpenStreetMap, the NUTS join, the codes nobody else knows — is under Data quality.

You do not need PHP for this

The dataset is the foundation. The PHP package is the reference implementation.

Every release ships the whole dataset as CSV and JSON, with a JSON Schema per file — so the identity model, the change history and the provenance rules are usable from any language, without reimplementing them from scratch. Download it and use it from anything:

curl -LO https://github.com/AchillesKal/greek-ota/releases/latest/download/municipalities.csv
jq -r '.[] | select(.elstatCode=="1402") | .name' municipalities.json
# Δήμος Βελβεντού

The time-travel query is a join on two files, in any language:

import csv

hist  = list(csv.DictReader(open('history/code-history.csv', encoding='utf-8')))
names = {r['id']: r['name'] for r in csv.DictReader(open('municipalities.csv', encoding='utf-8'))}

def resolve(code, year):
    """Who held this code at the end of `year`? Intervals are half-open."""
    at = f'{year}-12-31'
    for r in hist:
        if (r['code'] == code and r['id'].startswith('OTA-MUN-')
                and r['valid_from'] <= at and (not r['valid_until'] or at < r['valid_until'])):
            return names.get(r['id'], r['id'] + ' (abolished)')

print(resolve('1402', 2019))   # Δήμος Βοΐου
print(resolve('1402', 2020))   # Δήμος Βελβεντού

Files: seven tier files (.csv and .json), four history files, vintages.csv, and schemas/*.schema.json. Everything is documented in data/SCHEMA.md — column by column, with the nullability of every field and the reason for it.

Try it

docs/demo/ — a static page. Open index.html. Type an ELSTAT code, drag a year slider across 2012–2026, and watch 1402 stop meaning Δήμος Βοΐου. The holder is placed in its full hierarchy, decentralised administration down to its own children, with the links that moved when you dragged marked against the ones that did not. Also a browser for all seven tiers and a search box that shows you the Greek folds as you type.

It is one HTML file and three vanilla-JavaScript files: no framework, no build step, no package.json, and no PHP at run time — which is the point. 1 231 lines of JavaScript, of which the identity model, the Greek text layer and the search ranking are about 240; that is what consuming this data from another language actually costs. 101 kB of JSON on load, ready in about 100 ms, a year step repaints in under 2 ms, and it works from a file:// URL with no server at all.

examples/ — four PHP scripts. Each is self-contained and prints to the terminal.

php examples/01-dropdowns.php     # Περιφέρεια → Περιφερειακή ενότητα → Δήμος, the form case
php examples/02-stale-codes.php   # codes captured in 2019, read back today — the pitch
php examples/03-history.php       # codeHistory() and changes(), with ΦΕΚ and provenance
php examples/04-search.php        # Greek uppercasing, the genitive, and the two Ηράκλειο

What it covers

Seven tiers, 21,193 current entities, at the 2026 register vintage (as at 31 December 2025):

Tier Count
Αποκεντρωμένες Διοικήσεις 7 decentralised administrations
Περιφέρειες 13 regions (NUTS 2)
Περιφερειακές Ενότητες 75 regional units
Δήμοι 333 municipalities
Δημοτικές Ενότητες 1,037 municipal units
Δημοτικές Κοινότητες 6,142 communities
Οικισμοί 13,586 settlements

Plus the history behind them, covering the 2012–2026 series: 21,326 entities including 133 abolished, 25,097 code intervals, and 16,898 change records across 16,833 events.

Each entity carries its name in both cases as published, its Latin transliteration, NUTS code, 2021 census population, area, terrain class, and — for the tiers that have one — its έδρα with grammatical gender.

The current divisions are post-Kleisthenis:

Ν. 4555/2018 «ΚΛΕΙΣΘΕΝΗΣ Ι» — ΦΕΚ 133/Α/19-07-2018, in force 1 September 2019. Μεταρρύθμιση του θεσμικού πλαισίου της Τοπικής Αυτοδιοίκησης.

That is the legal basis for the shape of the data, and it is not a footnote: its ΦΕΚ is cited on 5,920 change rows in history/changes.csv, including the two mass conversions that restructured the community tier. It superseded Ν. 3852/2010 «ΚΑΛΛΙΚΡΑΤΗΣ», which is why a library named after Καλλικράτης is a library named after a superseded reform.

A naming note: ΟΤΑ strictly means the self-governing tiers, δήμοι and περιφέρειες. This dataset also carries the administrative tiers that complete the hierarchy — decentralised administrations, regional units, municipal units, communities, settlements — which are not ΟΤΑ. The Ota\ namespace is a shorthand for the domain, not a claim about the legal status of every tier in it.

Install

composer require achilleskal/greek-ota

PHP 8.2+, with ext-intl, ext-json and ext-mbstring. One runtime dependency, and it is an interface package: psr/simple-cache.

use Ota\Greece;

$greece = new Greece(Greece::DEFAULT_DATA_PATH);

// The reason this exists: resolve a positional code by when you stored it.
$greece->municipalities()->byElstatCode('1402');             // Δήμος Βελβεντού  (now)
$greece->municipalities()->byElstatCode('1402', asOf: 2019); // Δήμος Βοΐου      (end of 2019)
$greece->municipalities()->byElstatCode('1402', asOf: 2020); // Δήμος Βελβεντού  (end of 2020)

// Every code an entity has ever held, with dates and where each came from.
foreach ($greece->codeHistory($municipality) as $interval) {
    printf("%s  %s → %s  (%s)\n",
        $interval->code, $interval->validFrom, $interval->validUntil ?? 'current',
        $interval->provenance->value);
}
// 1405  2019-12-31 → 2020-12-31  (smd02)
// 1402  2020-12-31 → current     (elstat-crosswalk)

Stable identifiers are the answer to the whole problem: store OTA-MUN-00328, not 1402. Ids are minted once and never reissued — not even after an entity is abolished.

Lookups, traversal and search:

$settlement = $greece->settlements()->getByElstatCode('0101010101');

$settlement->name;                       // Κομοτηνή
$settlement->community()->name;          // Δημοτική Κοινότητα Κομοτηνής
$settlement->municipality()->name;       // Δήμος Κομοτηνής
$settlement->municipality()->region()->nutsCode;  // EL51

$greece->municipalities()->search('Δράμα');   // finds Δήμος Δράμας — genitive, accents, case
$greece->search('Σταυρούπολη');               // finds Σταυρούπολις — καθαρεύουσα, too
count($greece->settlements());                // 13586, without hydrating 13,586 objects

find() returns null; get() throws. A user-supplied code may legitimately have no answer; an id the dataset itself produced going missing is a broken invariant, and should not disappear into a null check.

Everything is lazy — constructing Greece reads nothing but the directory entry, and one lookup builds one object. Pass a PSR-16 cache and the parsed indexes survive between requests: a cached asOf lookup is 0.08 ms against 82 ms cold.

Two things people get wrong

1. asOf is a calendar year, not an edition year

Register editions are named for the year they were published, and edition N describes 31 December of N−1. So the "2020 edition" and asOf: 2020 are different things:

$greece->municipalities()->byElstatCode('1402', asOf: 2019);  // Δήμος Βοΐου      ← the 2020 edition
$greece->municipalities()->byElstatCode('1402', asOf: 2020);  // Δήμος Βελβεντού  ← the 2021 edition

asOf takes a calendar year because that needs no knowledge of ELSTAT's publication schedule. If you have an edition year, data/vintages.csv maps all fifteen to their as-of dates — including the three where the offset is 0 rather than 1, and the two editions that declare the same date.

2. The data is not MIT

The code is MIT (LICENSE). The data is not (LICENSE-DATA).

Everything under data/ is derived from Hellenic Statistical Authority publications and is redistributed under ELSTAT's own reuse policy. That policy permits commercial use and redistribution with no payment and no written licence, and requires attribution and a statement of modification. Both paragraphs — the citation alone does not satisfy it, because this project modifies the data:

Source: Hellenic Statistical Authority (ELSTAT), "Register of Municipalities,
Communities and Settlements" (SKA01), "Administrative Changes" (SMD02), and the
2021 Population-Housing Census. https://www.statistics.gr

This data has been modified from its original published form. ELSTAT bears no
responsibility for the result of the modification.

The Greek text, for Greek-language distributions, is in LICENSE-DATA §2. Put it wherever the data surfaces to an end user — an about page, an API's metadata, a footer. Do not describe this data as MIT, open data, or public domain.

Where each fact comes from

Every change and every code interval carries a provenance, and separately a confidence. Provenance answers how strongly does the government back this?register (read straight from a published edition), smd02 (in ELSTAT's change log, usually with a ΦΕΚ reference), elstat-crosswalk (official but administrative, like the 2021 re-coding), or derived (inferred here, because the published record explained nothing). Confidence answers a different question — how sure are we we read it right? — and the two are orthogonal, so a gazetted change can be medium-confidence and an inferred one can be unambiguous.

You care because it lets you decide what to trust without a second query:

$greece->changes($id);                   // everything
$greece->governmentBackedChanges($id);   // only what ELSTAT published

Of 16,898 change records, 13,744 are smd02, 2,701 are elstat-crosswalk, and 453 are derived — flagged, not hidden.

Data quality

Verified. data/SCHEMA.md §10 states 62 numbered referential assertions — id uniqueness and format, code widths and leading zeros, parent resolution and orphan counts, row counts per tier, name and seat coverage, measure nullability, the dual-key join for the two upper tiers, and the history files' internal consistency. Each is implemented one-for-one as a test, and the suite reads the specification at runtime so the two cannot drift. The national population reconciles to 10,482,487 independently at four tiers.

Known-defective. data/KNOWN-DEFECTS.md documents ten defects in ELSTAT's published files (D1–D10) — a settlement code stored as a float, two census files that contradict each other, a change-code dictionary that is redefined between eras, the missing record of the 2021 re-coding, the (Έδρα: …) clause vanishing from two sheets mid-series, two names published inside literal quote characters, 444 change rows citing no authority at all. Each entry says what is wrong, how it was verified, the repair applied, and how to recover the raw value. Every repair is logged to data/_build/repairs.csv; nothing is silent.

Corroborated. scripts/crosscheck.php checks the dataset against two sources that are not ELSTAT: Wikidata, which records ELSTAT codes as property P1116 on about 21 000 Greek items, and OpenStreetMap, joined on ref:nuts:2 / ref:nuts:3 and on the wikidata tag. Every self-consistency test in tests/ proves the dataset agrees with itself; this asks whether the places exist according to somebody else.

They do. All 13 NUTS-2 and all 39 tagged NUTS-3 codes in OSM match the nutsCode column exactly. Every ELSTAT code Wikidata publishes at municipality level or above is a code this history has issued; of 21 375 codes across all tiers, 14 are unknown here, and 13 of those are one mis-keyed block on Wikidata's side — settlements coded 720202xx (Ιεράπετρα) that its own P131 claims place in Σητεία, 7204. At municipal-unit level and above there are four unexplained name disagreements in total: three are καθαρεύουσα-versus-δημοτική — Ιερά Πόλη Μεσολογγίου at two tiers (Πόλης / Πόλεως) and Νέου Ψυχικού / Νέο Ψυχικό — and the fourth is Wikidata's long form of Άγιο Όρος. The four reconciled spelling disagreements, the ones the phonetic fold folds away rather than reports, are listed as non-defects in KNOWN-DEFECTS.md, so nobody has to rediscover them as bugs.

Nothing fetched is ever written into data/. A disagreement is resolved by reading the register, not by copying a name from the internet — importing a crowdsourced value to settle a sourced one is the failure this project exists to prevent. It is a manual tool, never a test: CI must not depend on two third-party endpoints, and most findings are about them rather than about us.

The 1 335 stale codes quoted at the top are the stale-external class of that report: a name Wikidata gives one of our codes that matches what the code meant at an earlier vintage of ours. Read against D6, that is the re-coding arriving at a consumer. It is not our defect and it is not Wikidata's carelessness — nothing was announced, so there was nothing for them to react to. Measured 2026-08-19; it is a live third-party dataset, so re-run the script rather than trusting the number.

Unresolved. 23 entities could not be explained by any published record, all in the 2020→2021 transition where ELSTAT's own crosswalk cites five codes the 2020 register does not contain. They are emitted as derived changes at low confidence and listed in data/_build/unresolved.csv. None was resolved by matching names — guessing identity from string similarity is exactly what this dataset exists to avoid.

Excluded, deliberately:

  • The 2011 edition. It breaks the parent-prefix invariant — 186 orphan communities and 489 orphan settlements, because the pseudo municipal-unit tier did not yet exist. Including it would mean inventing 89 rows ELSTAT never published, and minting durable ids for them.
  • Geometry. No boundaries, no coordinates. ELSTAT's registers are not geospatial products; shipping shapes would mean sourcing them elsewhere and silently changing the licence.
  • Postal codes. Out of scope: they are not part of ELSTAT's administrative register, which is the only source this dataset draws on.

Nothing under sources/ is ever modified. The archived spreadsheets stay byte-identical to what ELSTAT served, verifiable against a SHA-256 manifest.

Greek text

Searching Greek place names naively does not work, for five compounding reasons — and this is the part most libraries get wrong.

Uppercasing. Greek orthography drops the tonos in capitals; Unicode's default case mapping does not, because it is designed to be reversible:

Ota\Text\GreekCase::upper('Δήμος Κομοτηνής');  // ΔΗΜΟΣ ΚΟΜΟΤΗΝΗΣ  ← what the register holds
mb_strtoupper('Δήμος Κομοτηνής');              // ΔΉΜΟΣ ΚΟΜΟΤΗΝΉΣ  ← keeps the tonos

The second string does not appear anywhere in the register, so a comparison built on mb_strtoupper fails silently on every accented name — which is nearly all of them. The dialytika behaves differently and is preserved: ΒΟΪΟΥ is not ΒΟΙΟΥ.

Grammatical case. Administrative names are published in the genitive — Δήμος Δράμας — while people type the nominative they know, Δράμα. Settlements are the only tier in the nominative, and they carry their definite article: Κομοτηνή,η.

Accents and final sigma. Δράμα, δραμα and ΔΡΑΜΑ are the same query; σ and ς are the same letter.

Καθαρεύουσα. ELSTAT publishes the official form, and the official form is the old one: Σταυρούπολις, Νικόπολις, Στυλίδος. People type the δημοτική they speak — Σταυρούπολη, Νικόπολη, Στυλίδα. These are not near-misses. Modern Greek writes /i/ five ways — ι η υ ει οι — so Διστύων and Δυστίων are one word that two people spelled as they heard it, and Σταυρούπολις and Σταυρούπολη are one place. Ota\Text\GreekPhonetics folds them together.

Every word declines, not just the last one. Δημοτική ενότητα Νέας Χαλκηδόνος against a typed Νέα Χαλκηδόνα differs at both words, so a query compared as one token matches nothing. Each query word is stemmed and matched on its own, in any order, and all of them must match — which is what keeps a second word narrowing the answer rather than widening it.

All five are handled, and the phonetic fold is on by default — the register is written in καθαρεύουσα and its readers are not, so the fold that finds things is the one you get without asking:

$greece->municipalities()->search('Δράμα');   // → Δήμος Δράμας
$greece->municipalities()->search('δραμα');   // → Δήμος Δράμας
$greece->municipalities()->search('ΔΡΑΜΑ');   // → Δήμος Δράμας

$greece->search('Σταυρούπολη');               // → Σταυρούπολις — both of them, Θεσσαλονίκη and Ξάνθη
$greece->search('Νικόπολη');                  // → Νικόπολις, Νέα Νικόπολις
$greece->search('Νέα Χαλκηδόνα');             // → Νέας Χαλκηδόνος — both words folded, both required

Results are ranked, which is what makes that default safe: an exact name first, then the orthographic stem matches, then the phonetic ones. A precise query still puts the precise answer at the top; the loose layer only ever appends. searchStrict() turns it off for a caller who wants orthography and nothing else.

ΟΥ is deliberately not folded into /i/ — it is the one Greek vowel sound that stays distinct, so Λουτρά and Λίτρα do not collide. And a query whose fold collapses below four characters (ΣέρρεςΣΕΡ) skips the phonetic layer entirely: at that length it stops identifying anything, which is measurable — 32 extra results per query at three characters against 9 at four.

Duplicate names are returned, not silently collapsed — there are two Δήμος Ηρακλείου, one in Περιφερειακή ενότητα Ηρακλείου and one in Περιφερειακή ενότητα Βορείου Τομέα Αθηνών. Use $greece->describe($entity) to disambiguate, or read the parent chain yourself.

Seats come with gender, so you can write them into a sentence:

$m = $greece->municipalities()->getByElstatCode('0101');
$m->seat->name;            // Κομοτηνή
$m->seat->gender->value;   // η

Documentation

data/SCHEMA.md The dataset contract: every column, every nullability rule, the identity model, and the 62 assertions
data/KNOWN-DEFECTS.md Ten defects in the published sources, and what this project does about each
CONTRIBUTING.md Docker workflow, regenerating the data, the release checklist
CHANGELOG.md Versions, and the data vintage tracked separately from the API
SECURITY.md Reporting a vulnerability

Carrying ELSTAT codes stored from an older dataset? ⚠️ Any code you stored before the 2021 register may now resolve to a different place, with no failed join to warn you: that is exactly the silent re-coding this README opens on. Re-resolve stored codes with byElstatCode($code, asOf: $year) instead of joining on them bare, and read Two things people get wrong first.

Source and attribution

All data is derived from publications of the Hellenic Statistical Authority (ELSTAT) / Ελληνική Στατιστική Αρχή:

  • SKA01Μητρώο Δήμων, Κοινοτήτων & Οικισμών, editions 2012–2026
  • SMD02Διοικητικές Μεταβολές, all 14 in-scope transition files
  • 2021 Population–Housing Census, resident population results

This data has been modified from its original published form. ELSTAT bears no responsibility for the result of the modification. The full terms and the required attribution string, in English and Greek, are in LICENSE-DATA.

ELSTAT publishes this material openly and permits its reuse. Please cite them properly; it is a condition of the licence, not a courtesy.