-- postal_code: a 64-bit type for any country's postal code, additive -- to (not a replacement for) the GB-only postcode type above. See -- postal_code.h/postal_code_fmt.h for the bit layout and dispatch -- design; postal_code_us.c/ca/fr/br/cz/lu/gb/ie.c are the -- formats implemented so far. Country->format assignment itself lives in the -- postal_code_country_formats SQL table (add_country_format() / -- remove_country_format()), not compiled in -- see that section -- below. -- text form is the UPU one: ISO 3166-1 alpha-2, a hyphen, then the -- national code. A country prefix is required -- there is no implicit -- default country (unlike postcode, which is UK-only, there is no single -- obvious country to assume here). SELECT '90210'::postal_code; ERROR: postal_code requires a two-letter country prefix, e.g. "US-90210" LINE 1: SELECT '90210'::postal_code; ^ HINT: got "90210" SELECT '90210-1234'::postal_code; -- a ZIP+4 hyphen is not a country delimiter ERROR: postal_code requires a two-letter country prefix, e.g. "US-90210" LINE 1: SELECT '90210-1234'::postal_code; ^ HINT: got "90210-1234" SELECT 'U-90210'::postal_code; ERROR: postal_code requires a two-letter country prefix, e.g. "US-90210" LINE 1: SELECT 'U-90210'::postal_code; ^ HINT: got "U-90210" -- the old colon form is not accepted SELECT 'US:90210'::postal_code; ERROR: postal_code requires a two-letter country prefix, e.g. "US-90210" LINE 1: SELECT 'US:90210'::postal_code; ^ HINT: got "US:90210" -- unrecognised two-letter country SELECT 'ZZ-90210'::postal_code; ERROR: "ZZ" is not a supported country code LINE 1: SELECT 'ZZ-90210'::postal_code; ^ HINT: see postal_code_country_formats, or add one with add_country_format() -- basic round trips: US ZIP5, US ZIP5+4, CA full (FSA+LDU) SELECT 'US-90210'::postal_code; postal_code ------------- US-90210 (1 row) SELECT 'US-90210-1234'::postal_code; postal_code --------------- US-90210-1234 (1 row) SELECT 'CA-K1A 0B1'::postal_code; postal_code ------------- CA-K1A 0B1 (1 row) -- CA FSA-only: a complete, valid value in its own right, not a -- truncated fragment -- see postal_code_ca.c's own comment for why -- (real-world GeoNames data is overwhelmingly this shape for CA) SELECT 'CA-T0A'::postal_code; postal_code ------------- CA-T0A (1 row) -- tolerant of case and of the FSA/LDU space being omitted, same -- spirit as postcode's own text parser SELECT 'ca-k1a0b1'::postal_code = 'CA-K1A 0B1'::postal_code; ?column? ---------- t (1 row) -- 4 or 5 characters is neither a valid FSA-only nor a complete -- FSA+LDU value SELECT 'CA-T0A0'::postal_code; ERROR: cannot parse "T0A0" as a CA postal code LINE 1: SELECT 'CA-T0A0'::postal_code; ^ -- 0000 is not a real ZIP+4 add-on code (0 is reserved internally to -- mean "no +4 supplied") SELECT 'US-90210-0000'::postal_code; ERROR: cannot parse "90210-0000" as a US postal code LINE 1: SELECT 'US-90210-0000'::postal_code; ^ -- excluded Canadian letters: D/F/I/O/Q/U never appear in any letter -- position; W/Z additionally never appear as the first letter SELECT 'CA-D1A 0B1'::postal_code; ERROR: cannot parse "D1A 0B1" as a CA postal code LINE 1: SELECT 'CA-D1A 0B1'::postal_code; ^ SELECT 'CA-W1A 0B1'::postal_code; ERROR: cannot parse "W1A 0B1" as a CA postal code LINE 1: SELECT 'CA-W1A 0B1'::postal_code; ^ SELECT 'CA-K1W 0B1'::postal_code; -- W is fine outside the first position postal_code ------------- CA-K1W 0B1 (1 row) -- all-zero payload is a legitimate value in both formats (US -- "00000" with no +4, CA "A0A 0A0") -- regression guard for a real -- bug caught while wiring up this type: parse() used to signal -- failure by returning 0, which collides with these SELECT 'US-00000'::postal_code; postal_code ------------- US-00000 (1 row) SELECT 'CA-A0A 0A0'::postal_code; postal_code ------------- CA-A0A 0A0 (1 row) -- country() accessor SELECT country('US-90210'::postal_code), country('CA-K1A 0B1'::postal_code); country | country ---------+--------- US | CA (1 row) -- postal_code(postcode, cc), analogous to PostGIS's ST_GeomFromText(wkt, -- srid): for callers with the national code and the country as separate -- values. The country may also come from the postcode's own "CC-" prefix. SELECT postal_code('90210-1234', 'US'); postal_code --------------- US-90210-1234 (1 row) SELECT postal_code('k1a0b1', 'ca'); -- case-insensitive country too postal_code ------------- CA-K1A 0B1 (1 row) -- the country can come from cc, from the postcode's own prefix, or from both -- (which must agree). A prefix is exactly two letters then a hyphen, which -- no national format starts with (LU's 'L-1311' has one letter). SELECT postal_code('90210-1234', 'US') AS from_cc, postal_code('US-90210-1234', NULL) AS from_prefix, postal_code('US-90210-1234', 'us') AS both_agree, postal_code('us-90210-1234', 'US') AS prefix_case_insensitive, postal_code('L-1311', 'LU') AS lu_national_code_is_not_a_prefix, postal_code('LU-L-1311', NULL) AS lu_prefixed, postal_code(NULL, 'US') IS NULL AS null_postcode_is_null; from_cc | from_prefix | both_agree | prefix_case_insensitive | lu_national_code_is_not_a_prefix | lu_prefixed | null_postcode_is_null ---------------+---------------+---------------+-------------------------+----------------------------------+-------------+----------------------- US-90210-1234 | US-90210-1234 | US-90210-1234 | US-90210-1234 | LU-L-1311 | LU-L-1311 | t (1 row) SELECT postal_code('US-90210', 'CA'); -- cc and prefix disagree ERROR: country code "CA" does not match the "US-" prefix of "US-90210" HINT: pass NULL as the country code to use the prefix, or drop the prefix SELECT postal_code('90210', NULL); -- no country anywhere ERROR: "90210" has no country: no "CC-" prefix, and no country code was given HINT: write it as "US-90210", or pass the country as the second argument SELECT postal_code('90210', 'USA'); -- malformed country ERROR: "USA" is not a two-letter country code SELECT postal_code('U-90210', NULL); -- not a prefix, so no country ERROR: "U-90210" has no country: no "CC-" prefix, and no country code was given HINT: write it as "US-90210", or pass the country as the second argument SELECT postal_code('US-', NULL); -- a prefix with nothing after it ERROR: cannot parse "" as a US postal code -- Outcode-only is a complete, valid value wherever a country has a -- distinct outcode/incode structure (GB, IE, CA, US); where the leading -- digits are only implicitly an outcode (FR, CZ, LU) the full code is -- required. GeoNames agrees: every one of its GB (27,450) and IE (139) -- rows is outcode-only. SELECT 'GB-SW1A'::postal_code, 'GB-LS24'::postal_code, 'GB-M1'::postal_code; postal_code | postal_code | postal_code -------------+-------------+------------- GB-SW1A | GB-LS24 | GB-M1 (1 row) SELECT 'GB-SW1A 1AA'::postal_code, 'gb-sw1a1aa'::postal_code; postal_code | postal_code -------------+------------- GB-SW1A 1AA | GB-SW1A 1AA (1 row) SELECT 'IE-A65'::postal_code, 'IE-D6W'::postal_code, 'IE-A65 F4E2'::postal_code, 'ie-a65f4e2'::postal_code; postal_code | postal_code | postal_code | postal_code -------------+-------------+-------------+------------- IE-A65 | IE-D6W | IE-A65 F4E2 | IE-A65 F4E2 (1 row) SELECT 'US-90210'::postal_code, 'CA-K1A'::postal_code; postal_code | postal_code -------------+------------- US-90210 | CA-K1A (1 row) SELECT 'BR-08970'::postal_code, 'BR-08970-000'::postal_code; postal_code | postal_code -------------+-------------- BR-08970 | BR-08970-000 (1 row) SELECT 'FR-75001'::postal_code, 'CZ-110 00'::postal_code, 'CZ-11000'::postal_code, 'LU-L-1311'::postal_code, 'LU-1311'::postal_code; postal_code | postal_code | postal_code | postal_code | postal_code -------------+-------------+-------------+-------------+------------- FR-75001 | CZ-110 00 | CZ-110 00 | LU-L-1311 | LU-L-1311 (1 row) -- ... but a fragment is not a postcode: area-only, or an outcode plus a -- sector with no unit, or a prefix of a country whose outcode is implicit SELECT 'GB-SW'::postal_code; ERROR: cannot parse "SW" as a GB postal code LINE 1: SELECT 'GB-SW'::postal_code; ^ SELECT 'GB-SW1A 1'::postal_code; ERROR: cannot parse "SW1A 1" as a GB postal code LINE 1: SELECT 'GB-SW1A 1'::postal_code; ^ SELECT 'FR-750'::postal_code; ERROR: cannot parse "750" as a FR postal code LINE 1: SELECT 'FR-750'::postal_code; ^ SELECT 'CZ-110'::postal_code; ERROR: cannot parse "110" as a CZ postal code LINE 1: SELECT 'CZ-110'::postal_code; ^ SELECT 'LU-L-13'::postal_code; ERROR: cannot parse "L-13" as a LU postal code LINE 1: SELECT 'LU-L-13'::postal_code; ^ -- France: CEDEX is accepted and normalised away. The 5 digits are a real, -- distinct postcode (GeoNames' FR rows carrying a CEDEX suffix almost -- never have a bare row for the same 5 digits) and "CEDEX [n]" is routing -- for the address, not part of the code. Exactly NNNNN [CEDEX [n]] is -- accepted -- NOT "ignore whatever follows the digits". SELECT 'FR-75054 CEDEX 01'::postal_code, 'FR-75054 CEDEX'::postal_code, 'fr-75054 cedex 9'::postal_code; postal_code | postal_code | postal_code -------------+-------------+------------- FR-75054 | FR-75054 | FR-75054 (1 row) SELECT 'FR-75054 CEDEX 01'::postal_code = 'FR-75054'::postal_code AS cedex_normalised_away; cedex_normalised_away ----------------------- t (1 row) SELECT 'FR-78078 CITYSSIMO'::postal_code; ERROR: cannot parse "78078 CITYSSIMO" as a FR postal code LINE 1: SELECT 'FR-78078 CITYSSIMO'::postal_code; ^ SELECT 'FR-75001 foo'::postal_code; ERROR: cannot parse "75001 foo" as a FR postal code LINE 1: SELECT 'FR-75001 foo'::postal_code; ^ SELECT 'FR-75054 CEDEX 123'::postal_code; ERROR: cannot parse "75054 CEDEX 123" as a FR postal code LINE 1: SELECT 'FR-75054 CEDEX 123'::postal_code; ^ SELECT 'FR-75054CEDEX'::postal_code; ERROR: cannot parse "75054CEDEX" as a FR postal code LINE 1: SELECT 'FR-75054CEDEX'::postal_code; ^ -- Eircode letters are limited to A C D E F H K N P R T V W X Y, and only -- D6W breaks the letter-digit-digit routing key shape SELECT 'IE-B65'::postal_code; ERROR: cannot parse "B65" as a IE postal code LINE 1: SELECT 'IE-B65'::postal_code; ^ SELECT 'IE-D6X'::postal_code; ERROR: cannot parse "D6X" as a IE postal code LINE 1: SELECT 'IE-D6X'::postal_code; ^ SELECT 'IE-A65 F4O2'::postal_code; ERROR: cannot parse "A65 F4O2" as a IE postal code LINE 1: SELECT 'IE-A65 F4O2'::postal_code; ^ SELECT 'IE-A65 F4E'::postal_code; ERROR: cannot parse "A65 F4E" as a IE postal code LINE 1: SELECT 'IE-A65 F4E'::postal_code; ^ -- an outcode sorts immediately before every full code inside it, and -- outcodes still sort against each other SELECT 'GB-SW1A'::postal_code < 'GB-SW1A 1AA'::postal_code AS outcode_before_its_codes, 'GB-SW1A 2AA'::postal_code < 'GB-SW1B'::postal_code AS outcodes_still_ordered, 'IE-A65'::postal_code < 'IE-A65 F4E2'::postal_code AS routing_key_before_eircode, 'IE-A65 F4E2'::postal_code < 'IE-A66'::postal_code AS routing_key_dominates, 'BR-08970-999'::postal_code < 'BR-08971'::postal_code AS br_base_dominates_suffix; outcode_before_its_codes | outcodes_still_ordered | routing_key_before_eircode | routing_key_dominates | br_base_dominates_suffix --------------------------+------------------------+----------------------------+-----------------------+-------------------------- t | t | t | t | t (1 row) -- Ordering. This is the property the whole design hinges on: -- 1. country sorts as ISO 3166-1 alpha-2 TEXT order, unconditionally -- 2. within a country, a more precise variant of the same underlying -- code (a ZIP5+4, or a CA FSA's full LDU) interleaves immediately -- after the coarser value it refines, rather than being grouped -- apart from it by format SELECT 'CA-K1A 0B1'::postal_code < 'US-90210'::postal_code AS ca_before_us; ca_before_us -------------- t (1 row) SELECT 'US-90210'::postal_code < 'US-90210-1234'::postal_code AS zip5_before_plus4; zip5_before_plus4 ------------------- t (1 row) SELECT 'US-90210-9999'::postal_code < 'US-90211'::postal_code AS interleaves_by_value_not_format; interleaves_by_value_not_format --------------------------------- t (1 row) SELECT 'CA-T0A'::postal_code < 'CA-T0A 0A0'::postal_code AS fsa_before_its_own_ldu; fsa_before_its_own_ldu ------------------------ t (1 row) -- Real-world fixture data: rows genuinely present in the production -- GEONAMES.world table (SELECT country_code, postal_code FROM -- "@GEONAMES".world WHERE country_code IN ('US','CA')), not -- hand-invented -- see project notes for how this sample was pulled. -- 43,147 real US+CA rows from that table were verified to parse -- with zero failures against this exact encoder; this is a small, -- fixed, checked-in slice of that same real data for a hermetic -- regression run. CREATE TEMP TABLE geonames_sample (country_code text, national_code text); INSERT INTO geonames_sample VALUES ('US', '00501'), -- Holtsville, NY -- lowest real US ZIP ('US', '10001'), -- New York, NY ('US', '90210'), -- Beverly Hills, CA ('US', '99950'), -- Ketchikan, AK -- highest real US ZIP ('CA', 'B6L'), ('CA', 'R7B'), ('CA', 'V8G'), ('CA', 'R0A'), ('CA', 'T2E'), ('CA', 'E5L'), ('CA', 'G9X'), ('CA', 'V3E'), ('CA', 'L6Y'), ('CA', 'L7E'), ('CA', 'T0A'), ('CA', 'T0B'), ('CA', 'T3T 0E5'), -- one of the very few full FSA+LDU rows in the table ('CA', 'V3Y 0H2'), ('GB', 'TD5'), ('GB', 'KA18'), ('GB', 'EC2V'), ('GB', 'PH26'), ('GB', 'HP27'), ('GB', 'PE22'), ('GB', 'IP12'), ('GB', 'M24'), ('IE', 'F28'), ('IE', 'P72'), ('IE', 'R21'), ('IE', 'K67'), ('IE', 'D14'), ('IE', 'E41'), ('IE', 'H12'), ('IE', 'D6W'), ('FR', '75001'), ('FR', '04004'), ('FR', '75054 CEDEX 01'), -- real: a CEDEX code is its own postcode ('BR', '08970-000'), ('BR', '29640-000'), ('CZ', '507 52'), ('CZ', '751 25'), ('LU', 'L-1311'), ('LU', 'L-4942'); -- every row in the fixture must parse and round-trip cleanly SELECT country_code, national_code, postal_code(national_code, country_code) FROM geonames_sample ORDER BY country_code, national_code; country_code | national_code | postal_code --------------+----------------+-------------- BR | 08970-000 | BR-08970-000 BR | 29640-000 | BR-29640-000 CA | B6L | CA-B6L CA | E5L | CA-E5L CA | G9X | CA-G9X CA | L6Y | CA-L6Y CA | L7E | CA-L7E CA | R0A | CA-R0A CA | R7B | CA-R7B CA | T0A | CA-T0A CA | T0B | CA-T0B CA | T2E | CA-T2E CA | T3T 0E5 | CA-T3T 0E5 CA | V3E | CA-V3E CA | V3Y 0H2 | CA-V3Y 0H2 CA | V8G | CA-V8G CZ | 507 52 | CZ-507 52 CZ | 751 25 | CZ-751 25 FR | 04004 | FR-04004 FR | 75001 | FR-75001 FR | 75054 CEDEX 01 | FR-75054 GB | EC2V | GB-EC2V GB | HP27 | GB-HP27 GB | IP12 | GB-IP12 GB | KA18 | GB-KA18 GB | M24 | GB-M24 GB | PE22 | GB-PE22 GB | PH26 | GB-PH26 GB | TD5 | GB-TD5 IE | D14 | IE-D14 IE | D6W | IE-D6W IE | E41 | IE-E41 IE | F28 | IE-F28 IE | H12 | IE-H12 IE | K67 | IE-K67 IE | P72 | IE-P72 IE | R21 | IE-R21 LU | L-1311 | LU-L-1311 LU | L-4942 | LU-L-4942 US | 00501 | US-00501 US | 10001 | US-10001 US | 90210 | US-90210 US | 99950 | US-99950 (43 rows) CREATE TABLE addr (id serial primary key, pc postal_code); INSERT INTO addr (pc) SELECT postal_code(national_code, country_code) FROM geonames_sample; CREATE INDEX ON addr (pc); -- a real ORDER BY over a real (if small) index, not just direct -- comparisons -- CA sorts before US, and within CA the bare FSA-only -- rows interleave correctly against the two full FSA+LDU rows SELECT pc FROM addr ORDER BY pc; pc -------------- BR-08970-000 BR-29640-000 CA-B6L CA-E5L CA-G9X CA-L6Y CA-L7E CA-R0A CA-R7B CA-T0A CA-T0B CA-T2E CA-T3T 0E5 CA-V3E CA-V3Y 0H2 CA-V8G CZ-507 52 CZ-751 25 FR-04004 FR-75001 FR-75054 GB-EC2V GB-HP27 GB-IP12 GB-KA18 GB-M24 GB-PE22 GB-PH26 GB-TD5 IE-D14 IE-D6W IE-E41 IE-F28 IE-H12 IE-K67 IE-P72 IE-R21 LU-L-1311 LU-L-4942 US-00501 US-10001 US-90210 US-99950 (43 rows) -- to_postal_code(): NULL-returning counterparts of ::postal_code and -- postal_code(postcode, cc), for loading feeds with rows that are not valid -- postcodes (the role topostcode() plays for the UK type) -- a bad row -- gives NULL rather than an error that aborts the whole COPY. Strict -- parsing stays the default. SELECT to_postal_code('78078 CITYSSIMO', 'FR') IS NULL AS brand_name_is_null, to_postal_code('75001 SP 07', 'FR') IS NULL AS military_designator_is_null, to_postal_code('75054 CEDEX 01', 'FR') AS cedex_still_parses, to_postal_code('12345', 'XX') IS NULL AS unassigned_country_is_null, to_postal_code('12345', 'USA') IS NULL AS malformed_country_is_null, to_postal_code('nonsense', 'US') IS NULL AS unparseable_is_null, to_postal_code('90210', 'us') AS good_row_unchanged; brand_name_is_null | military_designator_is_null | cedex_still_parses | unassigned_country_is_null | malformed_country_is_null | unparseable_is_null | good_row_unchanged --------------------+-----------------------------+--------------------+----------------------------+---------------------------+---------------------+-------------------- t | t | FR-75054 | t | t | t | US-90210 (1 row) -- the one-argument form takes the same "CC-code" text as ::postal_code SELECT to_postal_code('FR-75054 CEDEX 01') AS cedex_ok, to_postal_code('us-90210-1234') AS zip4_ok, to_postal_code('FR-78078 CITYSSIMO') IS NULL AS bad_national_code, to_postal_code('90210') IS NULL AS no_country_prefix, to_postal_code('US:90210') IS NULL AS colon_form, to_postal_code('ZZ-90210') IS NULL AS unassigned_country, to_postal_code('') IS NULL AS empty, to_postal_code(NULL) IS NULL AS null_in; cedex_ok | zip4_ok | bad_national_code | no_country_prefix | colon_form | unassigned_country | empty | null_in ----------+---------------+-------------------+-------------------+------------+--------------------+-------+--------- FR-75054 | US-90210-1234 | t | t | t | t | t | t (1 row) -- the same prefix/cc rules: whatever would raise in the strict form is NULL here SELECT to_postal_code('US-90210', 'CA') IS NULL AS mismatch_is_null, to_postal_code('90210', NULL) IS NULL AS no_country_is_null, to_postal_code('US-90210', NULL) AS prefix_only, to_postal_code('90210', 'US') AS cc_only, to_postal_code('US-90210', 'us') AS both_agree; mismatch_is_null | no_country_is_null | prefix_only | cc_only | both_agree ------------------+--------------------+-------------+----------+------------ t | t | US-90210 | US-90210 | US-90210 (1 row) SELECT code, to_postal_code(code, 'FR') FROM (VALUES ('75001'), ('78078 CITYSSIMO'), ('75054 CEDEX 01'), ('AIR'), ('13001')) v(code); code | to_postal_code -----------------+---------------- 75001 | FR-75001 78078 CITYSSIMO | 75054 CEDEX 01 | FR-75054 AIR | 13001 | FR-13001 (5 rows) -- the inequality operators carry PostgreSQL's standard selectivity -- estimators, so the planner can use ANALYZE statistics for range -- predicates (without them a 1-row range was estimated at 25% of the table) SELECT oprname, oprrest::text, oprjoin::text FROM pg_operator WHERE oprleft = 'postal_code'::regtype AND oprright = 'postal_code'::regtype ORDER BY oprname; oprname | oprrest | oprjoin ---------+-------------+----------------- < | scalarltsel | scalarltjoinsel <= | scalarlesel | scalarlejoinsel <> | neqsel | neqjoinsel = | eqsel | eqjoinsel > | scalargtsel | scalargtjoinsel >= | scalargesel | scalargejoinsel (6 rows) -- is_valid_postal_code(): true/false instead of an error, e.g. for CHECK constraints -- or for finding the rejects in a staging table. NULL in gives NULL out. SELECT is_valid_postal_code('US-90210-1234') AS zip4, is_valid_postal_code('GB-SW1A') AS outcode, is_valid_postal_code('FR-75054 CEDEX 01') AS cedex, is_valid_postal_code('FR-78078 CITYSSIMO') AS brand, is_valid_postal_code('CA-D1A 0B1') AS excluded_canadian_letter, is_valid_postal_code('IE-B65') AS bad_eircode_letter, is_valid_postal_code('US-90210-0000') AS zip4_0000, is_valid_postal_code('90210') AS no_country, is_valid_postal_code('ZZ-90210') AS unassigned_country, is_valid_postal_code('GB-SW1A 1') AS fragment, is_valid_postal_code(NULL::text) AS null_in; zip4 | outcode | cedex | brand | excluded_canadian_letter | bad_eircode_letter | zip4_0000 | no_country | unassigned_country | fragment | null_in ------+---------+-------+-------+--------------------------+--------------------+-----------+------------+--------------------+----------+--------- t | t | t | f | f | f | f | f | f | f | (1 row) SELECT is_valid_postal_code('90210', 'US') AS good, is_valid_postal_code('nonsense', 'US') AS bad, is_valid_postal_code('1', 'xx') AS unassigned; good | bad | unassigned ------+-----+------------ t | f | f (1 row) SELECT is_valid_postal_code('US-90210', 'CA') AS mismatch, is_valid_postal_code('90210', NULL) AS no_country, is_valid_postal_code('US-90210', NULL) AS prefix_only, is_valid_postal_code('US-90210', 'US') AS both_agree, is_valid_postal_code(NULL, 'US') AS null_postcode; mismatch | no_country | prefix_only | both_agree | null_postcode ----------+------------+-------------+------------+--------------- f | f | t | t | (1 row) -- one function for both forms: the country is optional, and a bare name like is_valid() is not used (it collided -- with other extensions' is_valid in 2.0.0) SELECT is_valid_postal_code('US-90210') IS NOT DISTINCT FROM is_valid_postal_code('US-90210', NULL) AS country_is_optional, to_regprocedure('is_valid(text)') IS NULL AND to_regprocedure('is_valid(text, text)') IS NULL AS no_bare_is_valid; country_is_optional | no_bare_is_valid ---------------------+------------------ t | t (1 row) -- agrees with the strict parser on every row of the fixture, good or bad SELECT count(*) AS disagreements FROM (VALUES ('US','90210'),('CA','T0A'),('GB','SW1A'),('IE','D6W'),('FR','75054 CEDEX 01'), ('FR','CITYSSIMO'),('CA','D1A 0B1'),('US','1234'),('LU','1311'),('BR','08970-000'),('CZ','11000')) v(cc, code) WHERE is_valid_postal_code(code, cc) IS DISTINCT FROM (to_postal_code(code, cc) IS NOT NULL); disagreements --------------- 0 (1 row) -- Country->format assignment is a live SQL table, not compiled in: -- assigning a new country to an already-implemented format is a -- plain INSERT (via add_country_format()), no rebuild -- only a -- genuinely new format shape needs real C. See postal_code_country.c -- for how postal_code_in()/postal_code(text,text) look this up. SELECT name FROM postal_code_formats ORDER BY name COLLATE "C"; name --------- BR CA CZ FR GB IE LU US pattern (9 rows) SELECT iso2, format_name FROM postal_code_country_formats WHERE iso2 IN ('BR', 'CA', 'CZ', 'FR', 'LU', 'US', 'GB', 'GG', 'IM', 'JE', 'IE') ORDER BY iso2; iso2 | format_name ------+------------- BR | BR CA | CA CZ | CZ FR | FR GB | GB GG | GB IE | IE IM | GB JE | GB LU | LU US | US (11 rows) -- unassigned country, existing format shape: fails until assigned SELECT 'XZ-12345'::postal_code; ERROR: "XZ" is not a supported country code LINE 1: SELECT 'XZ-12345'::postal_code; ^ HINT: see postal_code_country_formats, or add one with add_country_format() -- another country uses a plain 5-digit code -- same shape as FR/CZ, so -- this needs no new encoder, just an assignment SELECT add_country_format('xz', 'FR'); -- lower-case cc is normalised add_country_format -------------------- (1 row) SELECT 'XZ-12345'::postal_code; postal_code ------------- XZ-12345 (1 row) SELECT country('XZ-12345'::postal_code); country --------- XZ (1 row) -- reassigning is idempotent / an upsert, not an error SELECT add_country_format('XZ', 'FR'); add_country_format -------------------- (1 row) -- unknown format name SELECT add_country_format('XX', 'NOPE'); ERROR: unknown postal_code format NOPE, must be one of: BR, CA, CZ, FR, GB, IE, LU, US, pattern (or use add_country_template() to define a new one) CONTEXT: PL/pgSQL function add_country_format(text,text) line 9 at RAISE -- malformed country code SELECT add_country_format('DEU', 'FR'); ERROR: country code must be exactly two letters, got DEU CONTEXT: PL/pgSQL function add_country_format(text,text) line 6 at RAISE SELECT add_country_format('1E', 'FR'); ERROR: country code must be exactly two letters, got 1E CONTEXT: PL/pgSQL function add_country_format(text,text) line 6 at RAISE -- removing an assignment: existing STORED values are unaffected -- -- decoding uses the format already packed into the value's own bits, -- never a fresh lookup -- but parsing NEW text for that country now -- fails CREATE TEMP TABLE xz_before_removal AS SELECT postal_code('12345', 'XZ') AS pc; SELECT remove_country_format('XZ'); remove_country_format ----------------------- (1 row) SELECT pc FROM xz_before_removal; -- still renders fine, unaffected by the removal pc ---------- XZ-12345 (1 row) SELECT 'XZ-12345'::postal_code; -- but parsing fresh text for XZ fails now ERROR: "XZ" is not a supported country code LINE 1: SELECT 'XZ-12345'::postal_code; ^ HINT: see postal_code_country_formats, or add one with add_country_format() -- ===== Partial match: fragments, bounds and ranges ============================ -- A fragment is "CC-" plus a PREFIX of the national code. Each format orders -- its values exactly as its text sorts, so a prefix is one contiguous range -- [lower_bound, upper_bound), and neighbouring prefixes tile. A fragment is -- not a value: 'FR-75' is a fragment but not a postcode. SELECT lower_bound('FR-75') AS lo, upper_bound('FR-75') AS hi; -- 2 of 5 digits lo | hi ----------+---------- FR-75000 | FR-76000 (1 row) SELECT lower_bound('FR-750') AS lo, upper_bound('FR-750') AS hi; -- 3 of 5 lo | hi ----------+---------- FR-75000 | FR-75100 (1 row) SELECT lower_bound('US-90210') AS lo, upper_bound('US-90210') AS hi; -- a bare ZIP5 covers its +4s too lo | hi ----------+---------- US-90210 | US-90211 (1 row) SELECT lower_bound('US-90210-1') AS lo, upper_bound('US-90210-1') AS hi; lo | hi ---------------+--------------- US-90210-1000 | US-90210-2000 (1 row) SELECT lower_bound('US-90210-9999') AS lo, upper_bound('US-90210-9999') AS hi; -- last add-on ends at the next ZIP5 lo | hi ---------------+---------- US-90210-9999 | US-90211 (1 row) SELECT lower_bound('BR-08970-0') AS lo, upper_bound('BR-08970-0') AS hi; lo | hi --------------+-------------- BR-08970-000 | BR-08970-100 (1 row) SELECT lower_bound('CA-K') AS lo, upper_bound('CA-K') AS hi; -- the smallest VALID value: an outcode lo | hi --------+-------- CA-K0A | CA-L0A (1 row) SELECT lower_bound('CA-V') AS lo, upper_bound('CA-V') AS hi; -- W is never a first letter lo | hi --------+-------- CA-V0A | CA-X0A (1 row) SELECT lower_bound('CA-K1C') AS lo, upper_bound('CA-K1C') AS hi; -- D is never used lo | hi --------+-------- CA-K1C | CA-K1E (1 row) SELECT lower_bound('CA-K1A 9') AS lo, upper_bound('CA-K1A 9') AS hi; -- carries out of the LDU to the next FSA lo | hi ------------+-------- CA-K1A 9A0 | CA-K1B (1 row) SELECT lower_bound('IE-A99') AS lo, upper_bound('IE-A99') AS hi; -- there is no B lo | hi --------+-------- IE-A99 | IE-C00 (1 row) SELECT lower_bound('IE-D69') AS lo, upper_bound('IE-D69') AS hi; -- ... and D6W sits between D69 and D70 lo | hi --------+-------- IE-D69 | IE-D6W (1 row) SELECT lower_bound('IE-D6W') AS lo, upper_bound('IE-D6W') AS hi; lo | hi --------+-------- IE-D6W | IE-D70 (1 row) SELECT lower_bound('GB-LS1') AS lo, upper_bound('GB-LS1') AS hi; -- district LS1 only, not LS1x lo | hi --------+--------- GB-LS1 | GB-LS10 (1 row) SELECT lower_bound('GB-LS19') AS lo, upper_bound('GB-LS19') AS hi; lo | hi ---------+--------- GB-LS19 | GB-LS1A (1 row) SELECT lower_bound('GB-SW1A 1') AS lo, upper_bound('GB-SW1A 1') AS hi; lo | hi -------------+------------- GB-SW1A 1AA | GB-SW1A 2AA (1 row) SELECT lower_bound('GB-ZE') AS lo, upper_bound('GB-ZE') AS hi; -- area list is append-only: GX follows ZE lo | hi --------+-------- GB-ZE0 | GB-GX0 (1 row) -- the top of a country has no successor value, so the upper bound is that -- country's end-of-country bound 'XX-~': after every real value of the country -- and before the next. It is a bound, never a postcode. SELECT lower_bound('US-99') AS lo, upper_bound('US-99') AS hi; lo | hi ----------+------ US-99000 | US-~ (1 row) SELECT lower_bound('CA-Y') AS lo, upper_bound('CA-Y') AS hi; lo | hi --------+------ CA-Y0A | CA-~ (1 row) SELECT lower_bound('GB-GX') AS lo, upper_bound('GB-GX') AS hi; lo | hi --------+------ GB-GX0 | GB-~ (1 row) SELECT lower_bound('IE-Y') AS lo, upper_bound('IE-Y') AS hi; lo | hi --------+------ IE-Y00 | IE-~ (1 row) SELECT 'US-99999'::postal_code < 'US-~'::postal_code AS after_the_last_us_code, 'US-~'::postal_code < 'ZA-~'::postal_code AS before_the_next_country, 'US-~'::postal_code > 'US-99999-9999'::postal_code AS after_the_last_us_plus4, country('US-~'::postal_code) AS still_knows_its_country; after_the_last_us_code | before_the_next_country | after_the_last_us_plus4 | still_knows_its_country ------------------------+-------------------------+-------------------------+------------------------- t | t | t | US (1 row) -- ... but it is not a postcode: nothing validates, parses or constructs it SELECT is_valid_postal_code('US-~') AS is_valid, to_postal_code('US-~') IS NULL AS to_postal_code_is_null; is_valid | to_postal_code_is_null ----------+------------------------ f | t (1 row) SELECT postal_code('~', 'US'); ERROR: cannot parse "~" as a US postal code SELECT 'US-'::postal_code; ERROR: cannot parse "" as a US postal code LINE 1: SELECT 'US-'::postal_code; ^ SELECT 'U1-~'::postal_code; ERROR: postal_code requires a two-letter country prefix, e.g. "US-90210" LINE 1: SELECT 'U1-~'::postal_code; ^ HINT: got "U1-~" -- postal_prefix(): the range itself, a native PostgreSQL range type SELECT postal_prefix('GB-LS24'); postal_prefix ------------------- [GB-LS24,GB-LS25) (1 row) SELECT postal_prefix('US-99'); -- ends at the end-of-country bound postal_prefix ----------------- [US-99000,US-~) (1 row) SELECT postal_prefix('CA-K1C'); postal_prefix ----------------- [CA-K1C,CA-K1E) (1 row) SELECT postal_prefix('IE-D6'); postal_prefix ----------------- [IE-D60,IE-D70) (1 row) SELECT postal_prefix('FR-75') @> 'FR-75054'::postal_code AS contains, postal_prefix('FR-75') @> 'FR-76000'::postal_code AS next_prefix_excluded, 'US-99999'::postal_code <@ postal_prefix('US-99') AS top_of_country_included; contains | next_prefix_excluded | top_of_country_included ----------+----------------------+------------------------- t | f | t (1 row) -- the top of one country must not run on into the next ones: BR sorts before CA SELECT 'CA-K1A'::postal_code <@ postal_prefix('BR-99') AS later_country_excluded, 'BR-99999-999'::postal_code <@ postal_prefix('BR-99') AS own_top_included; later_country_excluded | own_top_included ------------------------+------------------ f | t (1 row) -- neighbouring prefixes are adjacent: no gap and no overlap SELECT postal_prefix('CA-K1C') -|- postal_prefix('CA-K1E') AS skips_the_unused_D, postal_prefix('GB-LS19') -|- postal_prefix('GB-LS1A') AS digits_then_letters, postal_prefix('IE-D69') -|- postal_prefix('IE-D6W') AS d69_d6w, postal_prefix('IE-D6W') -|- postal_prefix('IE-D70') AS d6w_d70, postal_prefix('US-90210') -|- postal_prefix('US-90211') AS zips, postal_prefix('BR-08970') -|- postal_prefix('BR-08971') AS ceps; skips_the_unused_d | digits_then_letters | d69_d6w | d6w_d70 | zips | ceps --------------------+---------------------+---------+---------+------+------ t | t | t | t | t | t (1 row) -- things that are not fragments SELECT postal_prefix('FR-75A'); ERROR: cannot parse "75A" as a fragment of a FR postal code SELECT postal_prefix('FR-75001 CEDEX'); ERROR: cannot parse "75001 CEDEX" as a fragment of a FR postal code SELECT postal_prefix('CA-D'); ERROR: cannot parse "D" as a fragment of a CA postal code SELECT postal_prefix('IE-B'); ERROR: cannot parse "B" as a fragment of a IE postal code SELECT postal_prefix('US-90210-0000'); ERROR: cannot parse "90210-0000" as a fragment of a US postal code SELECT postal_prefix('75'); ERROR: a postal code fragment requires a two-letter country prefix, e.g. "GB-LS24" HINT: got "75" SELECT postal_prefix('ZZ-1'); ERROR: "ZZ" is not a supported country code HINT: see postal_code_country_formats, or add one with add_country_format() -- on the real-data fixture: a range matches exactly the values whose text -- starts with the fragment (fragments chosen where GB's hierarchical rule -- and plain text prefix agree) SELECT f.frag, count(*) FILTER (WHERE a.pc <@ postal_prefix(f.frag)) AS matches, count(*) FILTER (WHERE (a.pc <@ postal_prefix(f.frag)) IS DISTINCT FROM (a.pc::text LIKE f.frag || '%')) AS disagreements FROM addr a, (VALUES ('US-9'),('US-99'),('CA-T'),('CA-T0'),('CA-V'),('GB-PH'),('IE-D'),('FR-7'), ('BR-0'),('BR-08970'),('CZ-5'),('LU-L-4')) f(frag) GROUP BY f.frag ORDER BY f.frag; frag | matches | disagreements ----------+---------+--------------- BR-0 | 1 | 0 BR-08970 | 1 | 0 CA-T | 4 | 0 CA-T0 | 2 | 0 CA-V | 3 | 0 CZ-5 | 1 | 0 FR-7 | 2 | 0 GB-PH | 1 | 0 IE-D | 2 | 0 LU-L-4 | 1 | 0 US-9 | 2 | 0 US-99 | 1 | 0 (12 rows) -- a prefix search uses the index. A call with a constant fragment is folded -- into a constant range at plan time so PostgreSQL's own rewrite of -- "col <@ constant range" into btree conditions applies, the top of a country -- included. (Helper reports whether the plan has an index -- condition, which is stable output where the plan text itself is not.) CREATE FUNCTION pg_temp.uses_index_cond(q text) RETURNS boolean LANGUAGE plpgsql AS $$ DECLARE line text; BEGIN FOR line IN EXECUTE 'EXPLAIN (COSTS OFF) ' || q LOOP IF line LIKE '%Index Cond:%' THEN RETURN true; END IF; END LOOP; RETURN false; END $$; SET enable_seqscan = off; SET enable_bitmapscan = off; -- `col <@ ` becomes btree conditions only from PostgreSQL 17 (the planner's own support for range -- containment). Earlier versions give the right rows through a filter instead; there, `%` (tested below, and -- indexed on every version) or `pc >= lower_bound(x) AND pc < upper_bound(x)` is the indexed way. So this -- reports "index used, wherever the server can". SELECT (pg_temp.uses_index_cond($q$ SELECT * FROM addr WHERE pc <@ postal_prefix('GB-PH') $q$) OR current_setting('server_version_num')::int < 170000) AS bounded, (pg_temp.uses_index_cond($q$ SELECT * FROM addr WHERE pc <@ postal_prefix('US-99') $q$) OR current_setting('server_version_num')::int < 170000) AS unbounded_top, (pg_temp.uses_index_cond($q$ SELECT * FROM addr WHERE pc <@ postal_prefix('CA-K1C') $q$) OR current_setting('server_version_num')::int < 170000) AS skipping_unused_letter; bounded | unbounded_top | skipping_unused_letter ---------+---------------+------------------------ t | t | t (1 row) -- the explicit-bounds form is indexed on every version (STABLE functions of constants are evaluated once) SELECT pg_temp.uses_index_cond($q$ SELECT * FROM addr WHERE pc >= lower_bound('GB-PH') AND pc < upper_bound('GB-PH') $q$) AS explicit_bounds, pg_temp.uses_index_cond($q$ SELECT * FROM addr WHERE pc >= lower_bound('CA-K1C') AND pc < upper_bound('CA-K1C') $q$) AS explicit_bounds_ca; explicit_bounds | explicit_bounds_ca -----------------+-------------------- t | t (1 row) RESET enable_seqscan; RESET enable_bitmapscan; -- a fragment that is not a constant still works (computed per row; no folding) SELECT count(*) AS joined FROM (VALUES ('US-9'), ('CA-T')) f(frag) JOIN addr a ON a.pc <@ postal_prefix(f.frag); joined -------- 6 (1 row) -- ===== The % operator ======================================================= -- pc % 'fragment': does pc start with the fragment? The UK type's operator, -- ported. Same meaning as pc <@ postal_prefix(fragment), with its leniency: a -- fragment that isn't one matches nothing (and !% matches everything), since -- it is meant for arbitrary input such as a search box. SELECT 'GB-LS24 9JT'::postal_code % 'GB-LS24' AS inside, 'GB-LS25 9JT'::postal_code % 'GB-LS24' AS next_district, 'GB-LS24 9JT'::postal_code % 'GB-LS2' AS ls2_is_district_ls2_only, 'US-90210-1234'::postal_code % 'US-902' AS zip_prefix, 'US-90210-1234'::postal_code % 'US-90211' AS other_zip, 'CA-K1A 0B1'::postal_code % 'CA-K1' AS ca, 'IE-D6W'::postal_code % 'IE-D6' AS d6w_is_in_d6, 'FR-75054'::postal_code % 'FR-75' AS fr_in, 'FR-75054'::postal_code % 'FR-76' AS fr_out; inside | next_district | ls2_is_district_ls2_only | zip_prefix | other_zip | ca | d6w_is_in_d6 | fr_in | fr_out --------+---------------+--------------------------+------------+-----------+----+--------------+-------+-------- t | f | f | t | f | t | t | t | f (1 row) SELECT 'US-90210'::postal_code !% 'US-902' AS not_matching_is_false, 'US-90210'::postal_code !% 'US-903' AS not_matching_is_true; not_matching_is_false | not_matching_is_true -----------------------+---------------------- f | t (1 row) -- a bad fragment is "no match", not an error SELECT 'US-90210'::postal_code % 'nonsense' AS garbage, 'US-90210'::postal_code % '90210' AS no_country, 'US-90210'::postal_code % 'ZZ-1' AS unassigned_country, 'US-90210'::postal_code % 'US-9x' AS not_a_prefix, 'US-90210'::postal_code !% 'nonsense' AS negator_matches_everything, 'US-90210'::postal_code % NULL AS null_fragment; garbage | no_country | unassigned_country | not_a_prefix | negator_matches_everything | null_fragment ---------+------------+--------------------+--------------+----------------------------+--------------- f | f | f | f | t | (1 row) -- identical to the range form for every valid fragment, on the fixture -- (fragments where GB's hierarchical rule and plain text prefix agree) SELECT count(*) AS disagreements FROM addr a, (VALUES ('US-9'),('US-99'),('US-90210'),('CA-T'),('CA-T0'),('CA-V'),('GB-PH'),('IE-D'),('FR-7'), ('BR-0'),('BR-08970'),('CZ-5'),('LU-L-4'),('GB-GX'),('CA-Y')) f(frag) WHERE (a.pc % f.frag) IS DISTINCT FROM (a.pc <@ postal_prefix(f.frag)); disagreements --------------- 0 (1 row) -- uses a btree index for a constant fragment, the top of a country included SET enable_seqscan = off; SET enable_bitmapscan = off; SELECT pg_temp.uses_index_cond($q$ SELECT * FROM addr WHERE pc % 'GB-PH' $q$) AS bounded, pg_temp.uses_index_cond($q$ SELECT * FROM addr WHERE pc % 'US-99' $q$) AS top_of_country, pg_temp.uses_index_cond($q$ SELECT * FROM addr WHERE pc % 'nonsense' $q$) AS bad_fragment_is_left_alone; bounded | top_of_country | bad_fragment_is_left_alone ---------+----------------+---------------------------- t | t | f (1 row) RESET enable_seqscan; RESET enable_bitmapscan; -- a column fragment works too (computed per row) SELECT count(*) AS joined FROM (VALUES ('US-9'), ('CA-T'), ('bad')) f(frag) JOIN addr a ON a.pc % f.frag; joined -------- 6 (1 row) -- the cached-plan safety net, for % as for postal_prefix(): Country XY is -- assigned the plain-5-digit format, a row is stored, a statement is prepared -- (its plan cached), then Country XY is reassigned to the Czech format. The -- same prepared statement must now mean the CZ range. SELECT add_country_format('XY', 'FR'); add_country_format -------------------- (1 row) INSERT INTO addr (pc) VALUES ('XY-12345'); PREPARE xy_prefix AS SELECT pc FROM addr WHERE pc % 'XY-12'; EXECUTE xy_prefix; pc ---------- XY-12345 (1 row) EXECUTE xy_prefix; pc ---------- XY-12345 (1 row) SELECT add_country_format('XY', 'CZ'); add_country_format -------------------- (1 row) INSERT INTO addr (pc) VALUES ('XY-12345'); EXECUTE xy_prefix; pc ----------- XY-123 45 (1 row) DEALLOCATE xy_prefix; SELECT remove_country_format('XY'); remove_country_format ----------------------- (1 row) DELETE FROM addr WHERE pc::text LIKE 'XY-%'; -- ===== Locking a column to a country ======================================= -- postal_code('US') as a column type, the way PostGIS locks a geometry column -- to an SRID with geometry(Point, 4326): the type modifier is the country. SELECT format_type('postal_code'::regtype, 658) AS typmod_658_is, format_type('postal_code'::regtype, -1) AS unlocked; typmod_658_is | unlocked -----------------+------------- postal_code(US) | postal_code (1 row) CREATE TEMP TABLE us_only (id serial PRIMARY KEY, pc postal_code('US')); CREATE TEMP TABLE anywhere (id serial PRIMARY KEY, pc postal_code); -- a prefix is accepted (any case) if it agrees with the column's country INSERT INTO us_only (pc) VALUES ('US-90210'), ('us-90210-1234'), ('US-10001'), ('us-99950'), ('US-~'); SELECT id, pc FROM us_only ORDER BY id; id | pc ----+--------------- 1 | US-90210 2 | US-90210-1234 3 | US-10001 4 | US-99950 5 | US-~ (5 rows) -- ... and a different country is refused, however it is written INSERT INTO us_only (pc) VALUES ('CA-K1A 0B1'); ERROR: postal code of country "CA" does not match the column's country "US" HINT: the column is declared postal_code('US') INSERT INTO us_only (pc) VALUES ('GB-SW1A'); ERROR: postal code of country "GB" does not match the column's country "US" HINT: the column is declared postal_code('US') INSERT INTO us_only (pc) VALUES ('CA-~'); ERROR: postal code of country "CA" does not match the column's country "US" HINT: the column is declared postal_code('US') INSERT INTO us_only (pc) VALUES ('nonsense'); ERROR: postal_code requires a two-letter country prefix, e.g. "US-90210" LINE 1: INSERT INTO us_only (pc) VALUES ('nonsense'); ^ HINT: got "nonsense" INSERT INTO us_only (pc) VALUES ('US-'); ERROR: cannot parse "" as a US postal code LINE 1: INSERT INTO us_only (pc) VALUES ('US-'); ^ -- PostgreSQL hands a string literal to the input function without the column's -- type modifier (it applies the modifier afterwards), so in INSERT/UPDATE the -- prefix is always needed, locked column or not; only COPY can omit it (below) INSERT INTO us_only (pc) VALUES ('90210'); ERROR: postal_code requires a two-letter country prefix, e.g. "US-90210" LINE 1: INSERT INTO us_only (pc) VALUES ('90210'); ^ HINT: got "90210" INSERT INTO anywhere (pc) VALUES ('90210'); ERROR: postal_code requires a two-letter country prefix, e.g. "US-90210" LINE 1: INSERT INTO anywhere (pc) VALUES ('90210'); ^ HINT: got "90210" INSERT INTO anywhere (pc) VALUES ('US-90210'), ('CA-K1A 0B1'), ('GB-SW1A'); -- values built by functions are checked when they are assigned INSERT INTO us_only (pc) SELECT postal_code('90210-5678', 'US'); INSERT INTO us_only (pc) SELECT postal_code('SW1A', 'GB'); ERROR: postal code of country "GB" does not match the column's country "US" HINT: the column is declared postal_code('US') UPDATE us_only SET pc = 'CA-K1A 0B1' WHERE id = 1; ERROR: postal code of country "CA" does not match the column's country "US" HINT: the column is declared postal_code('US') UPDATE us_only SET pc = 'US-10002' WHERE id = 3; SELECT id, pc FROM us_only ORDER BY id; id | pc ----+--------------- 1 | US-90210 2 | US-90210-1234 3 | US-10002 4 | US-99950 5 | US-~ 6 | US-90210-5678 (6 rows) -- the cast form, and ALTER COLUMN ... TYPE (which fails if any existing value -- is from another country, and succeeds once they are gone) SELECT 'US-90210'::postal_code('US') AS ok; ok ---------- US-90210 (1 row) SELECT 'CA-K1A 0B1'::postal_code('US'); ERROR: postal code of country "CA" does not match the column's country "US" HINT: the column is declared postal_code('US') SELECT 'CA-K1A 0B1'::postal_code::postal_code('US'); ERROR: postal code of country "CA" does not match the column's country "US" HINT: the column is declared postal_code('US') ALTER TABLE anywhere ALTER COLUMN pc TYPE postal_code('US'); ERROR: postal code of country "CA" does not match the column's country "US" HINT: the column is declared postal_code('US') DELETE FROM anywhere WHERE pc <> 'US-90210'; ALTER TABLE anywhere ALTER COLUMN pc TYPE postal_code('US'); SELECT pc FROM anywhere; pc ---------- US-90210 (1 row) -- COPY does pass the modifier to the input function, so a bulk load into a -- locked column can use bare national codes COPY us_only (pc) FROM stdin; COPY us_only (pc) FROM stdin; ERROR: postal code of country "CA" does not match the column's country "US" HINT: the column is declared postal_code('US') CONTEXT: COPY us_only, line 2, column pc: "CA-K1A 0B1" SELECT pc FROM us_only ORDER BY id DESC LIMIT 3; pc --------------- US-10004 US-10003 US-90210-5678 (3 rows) -- a locked column is an ordinary postal_code column to everything else CREATE INDEX ON us_only (pc); SELECT count(*) AS zips_starting_9 FROM us_only WHERE pc % 'US-9'; zips_starting_9 ----------------- 4 (1 row) SELECT count(*) AS in_range FROM us_only WHERE pc <@ postal_prefix('US-1000'); in_range ---------- 3 (1 row) SELECT outcode(pc) AS outcode, country(pc) AS country FROM us_only WHERE id = 2; outcode | country ----------+--------- US-90210 | US (1 row) -- what is locked to what SELECT table_name, column_name, locked_to_country FROM postal_code_columns WHERE table_name IN ('us_only', 'anywhere') ORDER BY table_name, column_name; table_name | column_name | locked_to_country ------------+-------------+------------------- anywhere | pc | US us_only | pc | US (2 rows) -- bad modifiers CREATE TEMP TABLE bad1 (pc postal_code('USA')); ERROR: invalid type modifier "USA" for postal_code: expected a two-letter country code LINE 1: CREATE TEMP TABLE bad1 (pc postal_code('USA')); ^ HINT: for example postal_code('US') CREATE TEMP TABLE bad2 (pc postal_code('U1')); ERROR: invalid type modifier "U1" for postal_code: expected a two-letter country code LINE 1: CREATE TEMP TABLE bad2 (pc postal_code('U1')); ^ HINT: for example postal_code('US') CREATE TEMP TABLE bad3 (pc postal_code()); ERROR: syntax error at or near ")" LINE 1: CREATE TEMP TABLE bad3 (pc postal_code()); ^ CREATE TEMP TABLE bad4 (pc postal_code('US', 'CA')); ERROR: postal_code takes one type modifier, a two-letter country code LINE 1: CREATE TEMP TABLE bad4 (pc postal_code('US', 'CA')); ^ HINT: for example postal_code('US') CREATE TEMP TABLE lower_case_is_fine (pc postal_code('ca')); SELECT format_type(atttypid, atttypmod) AS declared FROM pg_attribute WHERE attrelid = 'lower_case_is_fine'::regclass AND attname = 'pc'; declared ----------------- postal_code(CA) (1 row) -- a lock to a country with nothing assigned is accepted (a column definition -- has to survive a restore before the assignment data does) but nothing can -- ever go into it CREATE TEMP TABLE nowhere (pc postal_code('ZZ')); INSERT INTO nowhere VALUES ('ZZ-12345'); ERROR: "ZZ" is not a supported country code LINE 1: INSERT INTO nowhere VALUES ('ZZ-12345'); ^ HINT: see postal_code_country_formats, or add one with add_country_format() SELECT is_valid_postal_code('12345', 'ZZ') AS can_anything_be_valid_there; can_anything_be_valid_there ----------------------------- f (1 row) -- ===== Patterns ============================================================== -- A country whose codes can be described needs only an SQL row: a template (N digit, A letter, X either, -- [ ] optional) or a regular expression, which defines the whole set of its codes. -- -- First, a pattern that was rolled back must not be remembered. Version 1 of XA is -- NAN in the first transaction and ANA in the second, and the backend caches -- what a version means; the second must not see the first. BEGIN; SELECT add_country_template('XA', 'NAN'); add_country_template ---------------------- (1 row) SELECT 'XA-1A2'::postal_code; postal_code ------------- XA-1A2 (1 row) ROLLBACK; BEGIN; SELECT add_country_template('XA', 'ANA'); add_country_template ---------------------- (1 row) SELECT 'XA-A1A'::postal_code; postal_code ------------- XA-A1A (1 row) SAVEPOINT s; SELECT 'XA-1A2'::postal_code; ERROR: cannot parse "1A2" as a XA postal code LINE 1: SELECT 'XA-1A2'::postal_code; ^ ROLLBACK TO s; ROLLBACK; SELECT 'XA-A1A'::postal_code; ERROR: "XA" is not a supported country code LINE 1: SELECT 'XA-A1A'::postal_code; ^ HINT: see postal_code_country_formats, or add one with add_country_format() SELECT count(*) AS user_languages_after_rollbacks FROM postal_code_languages WHERE NOT builtin; user_languages_after_rollbacks -------------------------------- 0 (1 row) -- patterns that are not patterns SELECT postal_code_pattern_check('NNNNN[-NNNN]') AS ok, postal_code_pattern_check('X') AS also_ok; ok | also_ok ----------------+---------- \d{5}(-\d{4})? | [0-9A-Z] (1 row) SELECT postal_code_pattern_check(''); ERROR: invalid postal code pattern "": a pattern cannot be empty HINT: a template (N digit, A letter, X either, [ ] optional: "NNNNN[-NNNN]") or a regular expression between slashes ("/\d{5}(-\d{4})?/") SELECT postal_code_pattern_check('nn'); ERROR: invalid postal code pattern "nn": a template is made of N (a digit), A (a letter), X (a digit or letter), spaces and hyphens, and [ ] around an optional part; for anything else write a regular expression between slashes, e.g. /\d{3}( HINT: a template (N digit, A letter, X either, [ ] optional: "NNNNN[-NNNN]") or a regular expression between slashes ("/\d{5}(-\d{4})?/") SELECT postal_code_pattern_check('NN-'); postal_code_pattern_check --------------------------- \d{2}- (1 row) SELECT postal_code_pattern_check('NN[N'); ERROR: invalid postal code pattern "NN[N": missing "]" HINT: a template (N digit, A letter, X either, [ ] optional: "NNNNN[-NNNN]") or a regular expression between slashes ("/\d{5}(-\d{4})?/") SELECT postal_code_pattern_check('NN[N][N]'); postal_code_pattern_check --------------------------- \d{2}(\d)?(\d)? (1 row) SELECT postal_code_pattern_check('XXXXXXXXXX'); ERROR: invalid postal code pattern "XXXXXXXXXX": pattern allows more than 2^48 codes, which will not fit in the 48 bits a code has \set VERBOSITY terse SELECT add_country_template('XA', 'NN?'); ERROR: invalid postal code pattern "NN?": a template is made of N (a digit), A (a letter), X (a digit or letter), spaces and hyphens, and [ ] around an optional part; for anything else write a regular expression between slashes, e.g. /\d{3}( SELECT add_country_template('PLX', 'NN'); ERROR: country code must be exactly two letters, got PLX \set VERBOSITY default -- Assigning countries. These persist until the end of the section (the -- languages themselves are permanent), so the checks below can fail freely. SELECT add_country_template('XA', 'NN-NNN'); add_country_template ---------------------- (1 row) SELECT add_country_template('XB', 'NNNN AA'); add_country_template ---------------------- (1 row) SELECT add_country_template('XC', 'NNN NN'); add_country_template ---------------------- (1 row) SELECT add_country_template('XD', 'NNNNN[-NNNN]'); add_country_template ---------------------- (1 row) SELECT add_country_template('XE', 'NNNN'); add_country_template ---------------------- (1 row) SELECT add_country_template('XF', 'NNNN'); -- each country has its own language add_country_template ---------------------- (1 row) SELECT add_country_template('pl', 'NN-NNN'); -- cc is normalised add_country_template ---------------------- (1 row) SELECT add_country_template('XA', 'NN-NNN'); -- the same again adds nothing add_country_template ---------------------- (1 row) SELECT iso2, version, source, pattern FROM postal_code_languages WHERE NOT builtin ORDER BY iso2, version; iso2 | version | source | pattern ------+---------+--------------+---------------- XA | 1 | NN-NNN | \d{2}-\d{3} XB | 1 | NNNN AA | \d{4} [A-Z]{2} XC | 1 | NNN NN | \d{3} \d{2} XD | 1 | NNNNN[-NNNN] | \d{5}(-\d{4})? XE | 1 | NNNN | \d{4} XF | 1 | NNNN | \d{4} (6 rows) SELECT iso2, format_name FROM postal_code_country_formats WHERE iso2 BETWEEN 'XA' AND 'XF' ORDER BY iso2; iso2 | format_name ------+------------- XA | pattern XB | pattern XC | pattern XD | pattern XE | pattern XF | pattern (6 rows) -- in, out, either case, separators optional SELECT 'XA-00-950'::postal_code AS a, 'xa-00950'::postal_code AS b, postal_code('00-950', 'XA') AS c, postal_code('00950', 'XA') AS d; a | b | c | d -----------+-----------+-----------+----------- XA-00-950 | XA-00-950 | XA-00-950 | XA-00-950 (1 row) SELECT 'XB-1012 jl'::postal_code AS a, 'XB-1012JL'::postal_code AS b; a | b ------------+------------ XB-1012 JL | XB-1012 JL (1 row) SELECT 'XC-114 55'::postal_code, 'XD-98000'::postal_code AS bare, 'XD-98000-0001'::postal_code AS plus4; postal_code | bare | plus4 -------------+----------+--------------- XC-114 55 | XD-98000 | XD-98000-0001 (1 row) SELECT 'XD-98000-0000'::postal_code AS zeros_are_fine_in_a_template; zeros_are_fine_in_a_template ------------------------------ XD-98000-0000 (1 row) -- bad input says why SELECT 'XA-0O-950'::postal_code; ERROR: cannot parse "0O-950" as a XA postal code LINE 1: SELECT 'XA-0O-950'::postal_code; ^ SELECT 'XA-00-9500'::postal_code; ERROR: cannot parse "00-9500" as a XA postal code LINE 1: SELECT 'XA-00-9500'::postal_code; ^ SELECT 'XA-00-95'::postal_code; ERROR: cannot parse "00-95" as a XA postal code LINE 1: SELECT 'XA-00-95'::postal_code; ^ SELECT 'XB-1012 J'::postal_code; ERROR: cannot parse "1012 J" as a XB postal code LINE 1: SELECT 'XB-1012 J'::postal_code; ^ SELECT 'XB-1012 11'::postal_code; ERROR: cannot parse "1012 11" as a XB postal code LINE 1: SELECT 'XB-1012 11'::postal_code; ^ SELECT 'XD-98000-'::postal_code; ERROR: cannot parse "98000-" as a XD postal code LINE 1: SELECT 'XD-98000-'::postal_code; ^ SELECT to_postal_code('XA-00-9500') AS null_not_error, is_valid_postal_code('XA-00-950') AS yes, is_valid_postal_code('XA-00-95') AS no; null_not_error | yes | no ----------------+-----+---- | t | f (1 row) -- ordering is text ordering; a coarser value sorts before the finer ones, and -- countries stay in ISO order whichever kind of format they use SELECT pc FROM (VALUES ('XD-98000-0001'), ('XD-98000'), ('XD-97999-9999'), ('XD-98001'), ('XD-98000-0000'), ('XB-1012 JL'), ('XB-1012 JA'), ('XB-1011 ZZ'), ('XD-~'), ('XA-00-950'), ('XA-00-949'), ('GB-SW1A'), ('XE-1010'), ('US-90210')) v(t), LATERAL (SELECT t::postal_code AS pc) x ORDER BY pc; pc --------------- GB-SW1A US-90210 XA-00-949 XA-00-950 XB-1011 ZZ XB-1012 JA XB-1012 JL XD-97999-9999 XD-98000 XD-98000-0000 XD-98000-0001 XD-98001 XD-~ XE-1010 (14 rows) -- outcode: the part before the optional group, if the template has one SELECT pc, outcode(pc) AS outcode, district(pc) AS district FROM (VALUES ('XD-98000-0001'), ('XD-98000'), ('XA-00-950'), ('XB-1012 JL')) v(t), LATERAL (SELECT t::postal_code AS pc) x; pc | outcode | district ---------------+----------+---------- XD-98000-0001 | XD-98000 | XD-98000 XD-98000 | XD-98000 | XD-98000 XA-00-950 | | XB-1012 JL | | (4 rows) SELECT outcode(outcode('XD-98000-0001')) = outcode('XD-98000-0001') AS idempotent; idempotent ------------ t (1 row) -- fragments, bounds and ranges, as for the compiled formats SELECT lower_bound('XA-00') AS lo, upper_bound('XA-00') AS hi; lo | hi -----------+----------- XA-00-000 | XA-01-000 (1 row) SELECT lower_bound('XA-00-9') AS lo, upper_bound('XA-00-9') AS hi; lo | hi -----------+----------- XA-00-900 | XA-01-000 (1 row) SELECT lower_bound('XA-99') AS lo, upper_bound('XA-99') AS hi_is_the_end_of_the_country; lo | hi_is_the_end_of_the_country -----------+------------------------------ XA-99-000 | XA-~ (1 row) SELECT lower_bound('XB-10') AS lo, upper_bound('XB-10') AS hi; lo | hi ------------+------------ XB-1000 AA | XB-1100 AA (1 row) SELECT lower_bound('XB-1012 J') AS lo, upper_bound('XB-1012 J') AS hi; lo | hi ------------+------------ XB-1012 JA | XB-1012 KA (1 row) SELECT lower_bound('XB-1012 ZZ') AS lo, upper_bound('XB-1012 ZZ') AS hi; lo | hi ------------+------------ XB-1012 ZZ | XB-1013 AA (1 row) SELECT lower_bound('XD-98000') AS lo, upper_bound('XD-98000') AS hi; lo | hi ----------+---------- XD-98000 | XD-98001 (1 row) SELECT lower_bound('XD-98000-') AS lo, upper_bound('XD-98000-') AS hi; lo | hi ---------------+---------- XD-98000-0000 | XD-98001 (1 row) SELECT lower_bound('XD-98000-12') AS lo, upper_bound('XD-98000-12') AS hi; lo | hi ---------------+--------------- XD-98000-1200 | XD-98000-1300 (1 row) SELECT lower_bound('XD-98000-9999') AS lo, upper_bound('XD-98000-9999') AS hi; lo | hi ---------------+---------- XD-98000-9999 | XD-98001 (1 row) SELECT lower_bound('XD-99999-99') AS lo, upper_bound('XD-99999-99') AS hi_is_the_end_of_the_country; lo | hi_is_the_end_of_the_country ---------------+------------------------------ XD-99999-9900 | XD-~ (1 row) SELECT postal_prefix('XC-11') AS r, 'XC-114 55'::postal_code <@ postal_prefix('XC-11') AS inside; r | inside ---------------------------+-------- ["XC-110 00","XC-120 00") | t (1 row) SELECT lower_bound('XA-00-9500'); ERROR: cannot parse "00-9500" as a fragment of a XA postal code SELECT lower_bound('XA-A'); ERROR: cannot parse "A" as a fragment of a XA postal code SELECT lower_bound('XB-1012 JLX'); ERROR: cannot parse "1012 JLX" as a fragment of a XB postal code -- partial match SELECT 'XD-98000-0001'::postal_code % 'XD-98', 'XD-98000'::postal_code % 'XD-98000-', 'XD-97999'::postal_code % 'XD-98', 'XB-1012 JL'::postal_code % 'XB-1012 J', 'XB-1012 JL'::postal_code !% 'XB-1013', 'XA-00-950'::postal_code % 'XA-0'; ?column? | ?column? | ?column? | ?column? | ?column? | ?column? ----------+----------+----------+----------+----------+---------- t | f | f | t | t | t (1 row) -- all of it over an index CREATE TEMP TABLE zips (id serial, pc postal_code); INSERT INTO zips (pc) SELECT postal_code(lpad(g::text, 5, '0'), 'XD') FROM generate_series(95000, 99999, 7) g; INSERT INTO zips (pc) SELECT postal_code(lpad((95000 + g)::text, 5, '0') || '-' || lpad(g::text, 4, '0'), 'XD') FROM generate_series(1, 4000, 13) g; CREATE INDEX ON zips (pc); ANALYZE zips; SELECT count(*) AS by_range FROM zips WHERE pc <@ postal_prefix('XD-9812'); by_range ---------- 3 (1 row) SELECT count(*) AS by_text FROM zips WHERE pc::text LIKE 'XD-9812%'; by_text --------- 3 (1 row) SELECT count(*) AS by_op FROM zips WHERE pc % 'XD-9812'; by_op ------- 3 (1 row) SELECT count(*) AS plus4s FROM zips WHERE pc % 'XD-96000-0'; plus4s -------- 0 (1 row) SELECT count(*) AS by_text FROM zips WHERE pc::text LIKE 'XD-96000-0%'; by_text --------- 0 (1 row) SET enable_seqscan = off; EXPLAIN (COSTS OFF) SELECT * FROM zips WHERE pc % 'XD-9812'; QUERY PLAN ------------------------------------------------------------------------------------ Index Scan using zips_pc_idx on zips Index Cond: ((pc >= 'XD-98120'::postal_code) AND (pc < 'XD-98130'::postal_code)) (2 rows) RESET enable_seqscan; -- a column locked to a templated country CREATE TEMP TABLE pl (pc postal_code('XA')); INSERT INTO pl VALUES ('XA-00-950'); COPY pl FROM stdin; INSERT INTO pl VALUES ('XB-1012 JL'); ERROR: postal code of country "XB" does not match the column's country "XA" HINT: the column is declared postal_code('XA') SELECT pc FROM pl ORDER BY pc; pc ----------- XA-00-950 XA-01-001 (2 rows) -- a country moves to a different pattern: new values follow the new one, values -- already stored go on reading as they were written CREATE TEMP TABLE pl_old AS SELECT 'XA-00-950'::postal_code AS pc; SELECT add_country_template('XA', 'NNNNN'); add_country_template ---------------------- (1 row) SELECT 'XA-00950'::postal_code AS new_style; new_style ----------- XA-00950 (1 row) SELECT 'XA-00-950'::postal_code; ERROR: cannot parse "00-950" as a XA postal code LINE 1: SELECT 'XA-00-950'::postal_code; ^ SELECT pc AS still_reads_as_written, outcode(pc) IS NULL AS no_outcode FROM pl_old; still_reads_as_written | no_outcode ------------------------+------------ XA-00-950 | t (1 row) SELECT pc = 'XA-00950'::postal_code AS different_format_different_value FROM pl_old; different_format_different_value ---------------------------------- f (1 row) SELECT iso2, version, source FROM postal_code_languages WHERE NOT builtin AND iso2 = 'XA' ORDER BY version; iso2 | version | source ------+---------+-------- XA | 1 | NN-NNN XA | 2 | NNNNN (2 rows) -- a language is permanent once written: values are read back through it \set VERBOSITY terse UPDATE postal_code_languages SET source = 'NNNNN' WHERE iso2 = 'XA' AND version = 1; ERROR: postal_code_languages rows are permanent: stored postal_code values are decoded with them DELETE FROM postal_code_languages WHERE iso2 = 'XA' AND version = 1; ERROR: postal_code_languages rows are permanent: stored postal_code values are decoded with them TRUNCATE postal_code_languages; ERROR: postal_code_languages rows are permanent: stored postal_code values are decoded with them \set VERBOSITY default SELECT count(*) AS languages_kept FROM postal_code_languages WHERE NOT builtin AND iso2 = 'XA'; languages_kept ---------------- 2 (1 row) SELECT remove_country_format(cc) FROM unnest(ARRAY['XA', 'XB', 'XC', 'XD', 'XE', 'XF']) cc; remove_country_format ----------------------- (6 rows) -- a country can have 51 languages (versions), however many other countries have; XA already has two BEGIN; SELECT count(add_country_template('XA', t)) AS filled FROM ( SELECT t FROM ( SELECT repeat('N', a) || repeat('A', b) AS t FROM generate_series(1, 7) a, generate_series(1, 5) b UNION ALL SELECT repeat('X', c) FROM generate_series(1, 9) c UNION ALL SELECT repeat('N', c) FROM generate_series(1, 9) c UNION ALL SELECT repeat('A', c) FROM generate_series(1, 6) c ) q WHERE t NOT IN (SELECT source FROM postal_code_languages WHERE iso2 = 'XA') ORDER BY t LIMIT 51 - (SELECT count(*) FROM postal_code_languages WHERE iso2 = 'XA') ) f; filled -------- 49 (1 row) SELECT count(*) AS versions, min(version), max(version) FROM postal_code_languages WHERE iso2 = 'XA'; versions | min | max ----------+-----+----- 51 | 1 | 51 (1 row) SAVEPOINT all_taken; SELECT add_country_template('XA', 'NNNNNNNNNNN'); ERROR: country XA already has 51 postal_code languages, the most there is room for CONTEXT: PL/pgSQL function add_country_template(text,text) line 20 at RAISE ROLLBACK TO all_taken; SELECT add_country_template('XB', 'NNNNNNNNNNN') IS NOT NULL AS another_country_is_unaffected; another_country_is_unaffected ------------------------------- t (1 row) ROLLBACK; -- ===== Regular expressions ===================================================== -- A regular expression says what a template cannot: which letters may appear where, several lengths, -- a fixed prefix, that 0000 is not a code. It is bounded (no * or +), upper case, written between slashes. SELECT postal_code_pattern_check('/[1-9]\d{3}( [A-Z]{2})?/') AS ok, postal_code_pattern_size('/[1-9]\d{3}( [A-Z]{2})?/') AS codes; ok | codes ------------------------+--------- [1-9]\d{3}( [A-Z]{2})? | 6093000 (1 row) SELECT postal_code_pattern_size('NNNNN[-NNNN]') AS zip_plus_4, postal_code_pattern_size('NN-NNN') AS pl, postal_code_pattern_size('/[A-CE]\d[AC-FHKNPRTV-Y]/') AS some; zip_plus_4 | pl | some ------------+--------+------ 1000100000 | 100000 | 600 (1 row) \set VERBOSITY terse SELECT postal_code_pattern_check('/\d+/'); ERROR: invalid postal code pattern "/\d+/": "+" would allow codes of any length; use {m,n} (at most 40) SELECT postal_code_pattern_check('/\d*/'); ERROR: invalid postal code pattern "/\d*/": "*" would allow codes of any length; use {m,n} (at most 40) SELECT postal_code_pattern_check('/./'); ERROR: invalid postal code pattern "/./": "." is not allowed: write the characters out as a class, e.g. [0-9A-Z] SELECT postal_code_pattern_check('/(?=\d)\d/'); ERROR: invalid postal code pattern "/(?=\d)\d/": only (?: ) grouping is supported, not lookahead or other (?...) forms SELECT postal_code_pattern_check('/[a-z]/'); ERROR: invalid postal code pattern "/[a-z]/": write letters in upper case (codes are upper case; input is folded before matching) SELECT postal_code_pattern_check('/\d{3}'); ERROR: invalid postal code pattern "/\d{3}": a regular expression is written between slashes: /\d{5}/ SELECT postal_code_pattern_check('/(\d/'); ERROR: invalid postal code pattern "/(\d/": missing ")" SELECT postal_code_pattern_check('/\d{3,2}/'); ERROR: invalid postal code pattern "/\d{3,2}/": {3,2}: the maximum is below the minimum SELECT postal_code_pattern_check('/[A-Z]{20}[A-Z]{21}/'); ERROR: invalid postal code pattern "/[A-Z]{20}[A-Z]{21}/": pattern allows more than 2^48 codes, which will not fit in the 48 bits a code has SELECT postal_code_pattern_check('/[A-Z]{12}/'); ERROR: invalid postal code pattern "/[A-Z]{12}/": pattern allows more than 2^48 codes, which will not fit in the 48 bits a code has \set VERBOSITY default -- an invented country: four digits, not starting 0, and optionally two letters from a restricted set SELECT add_country_template('XK', '/[1-9]\d{3}( [ABD-HJLNP-UW-Z]{2})?/'); add_country_template ---------------------- (1 row) SELECT 'XK-1234'::postal_code AS a, 'xk-1234 ab'::postal_code AS b, 'XK-1234AB'::postal_code AS c; a | b | c ---------+------------+------------ XK-1234 | XK-1234 AB | XK-1234 AB (1 row) SELECT 'XK-0123'::postal_code; ERROR: cannot parse "0123" as a XK postal code LINE 1: SELECT 'XK-0123'::postal_code; ^ SELECT 'XK-1234 CC'::postal_code; ERROR: cannot parse "1234 CC" as a XK postal code LINE 1: SELECT 'XK-1234 CC'::postal_code; ^ SELECT 'XK-1234 A'::postal_code; ERROR: cannot parse "1234 A" as a XK postal code LINE 1: SELECT 'XK-1234 A'::postal_code; ^ SELECT to_postal_code('XK-1234 IO') AS null_not_error, is_valid_postal_code('1234 AB', 'XK') AS yes, is_valid_postal_code('1234 AI', 'XK') AS no; null_not_error | yes | no ----------------+-----+---- | t | f (1 row) SELECT pc, outcode(pc) FROM (VALUES ('XK-9999 ZZ'), ('XK-1000'), ('XK-1000 AA'), ('XK-5555'), ('XK-1000 ZZ')) v(t), LATERAL (SELECT t::postal_code AS pc) x ORDER BY pc; pc | outcode ------------+--------- XK-1000 | XK-1000 XK-1000 AA | XK-1000 XK-1000 ZZ | XK-1000 XK-5555 | XK-5555 XK-9999 ZZ | XK-9999 (5 rows) -- every range bound is a real code: no wasted bits SELECT lower_bound('XK-1') AS lo, upper_bound('XK-1') AS hi; lo | hi ---------+--------- XK-1000 | XK-2000 (1 row) SELECT lower_bound('XK-9999') AS lo, upper_bound('XK-9999') AS hi_is_the_end_of_the_country; lo | hi_is_the_end_of_the_country ---------+------------------------------ XK-9999 | XK-~ (1 row) SELECT lower_bound('XK-1234 A') AS lo, upper_bound('XK-1234 A') AS hi; lo | hi ------------+------------ XK-1234 AA | XK-1234 BA (1 row) SELECT 'XK-1234 AB'::postal_code % 'XK-12' AS yes, 'XK-1234'::postal_code % 'XK-1234 A' AS no; yes | no -----+---- t | f (1 row) SELECT remove_country_format('XK'); remove_country_format ----------------------- (1 row) -- ===== Countries whose own letters are part of the code ==================== -- The British Virgin Islands write VG1110, Andorra AD500, Azerbaijan AZ 1000: the ISO letters are -- written in front. Their patterns leave the letters out; on input they are accepted (they must be the -- country's own) and are neither stored nor written back, so all the spellings are one value in the UPU form. SELECT add_country_template('XG', 'NNNN'); add_country_template ---------------------- (1 row) SELECT add_country_template('XH', 'NNNN'); add_country_template ---------------------- (1 row) SELECT add_country_template('XJ', 'N-NNNN'); add_country_template ---------------------- (1 row) SELECT postal_code('XG1110', 'XG') AS a, postal_code('xg-1110', 'XG') AS b, postal_code('1110', 'XG') AS c, 'XG-XG1110'::postal_code AS d, 'XG-XG-1110'::postal_code AS e, 'XG-1110'::postal_code AS f; a | b | c | d | e | f ---------+---------+---------+---------+---------+--------- XG-1110 | XG-1110 | XG-1110 | XG-1110 | XG-1110 | XG-1110 (1 row) SELECT postal_code('XG1110', 'XG') = postal_code('1110', 'XG') AS same_value, 'XG-XG1110'::postal_code::text AS written_back; same_value | written_back ------------+-------------- t | XG-1110 (1 row) SELECT 'XH-XH 1000'::postal_code AS a, 'XH-xh1000'::postal_code AS b, 'XH-1000'::postal_code AS c; a | b | c ---------+---------+--------- XH-1000 | XH-1000 | XH-1000 (1 row) SELECT 'XJ-XJ1-1100'::postal_code AS a, 'XJ-1-1100'::postal_code AS b; a | b -----------+----------- XJ-1-1100 | XJ-1-1100 (1 row) -- somebody else's letters are not the country's SELECT 'XG-AB1110'::postal_code; ERROR: cannot parse "AB1110" as a XG postal code LINE 1: SELECT 'XG-AB1110'::postal_code; ^ SELECT 'XG-X1110'::postal_code; ERROR: cannot parse "X1110" as a XG postal code LINE 1: SELECT 'XG-X1110'::postal_code; ^ SELECT 'XG-XG'::postal_code; ERROR: cannot parse "XG" as a XG postal code LINE 1: SELECT 'XG-XG'::postal_code; ^ SELECT to_postal_code('XG-AB1110') AS null_not_error, is_valid_postal_code('XG1110', 'XG') AS yes, is_valid_postal_code('AB1110', 'XG') AS no; null_not_error | yes | no ----------------+-----+---- | t | f (1 row) -- ... and it works for fragments, ranges and the operator the same way SELECT lower_bound('XG-XG11') AS lo, upper_bound('XG-XG11') AS hi, lower_bound('XG-11') = lower_bound('XG-XG11') AS same; lo | hi | same ---------+---------+------ XG-1100 | XG-1200 | t (1 row) SELECT 'XG-XG1110'::postal_code % 'XG-XG11' AS yes, 'XG-1110'::postal_code % 'XG-12' AS no; yes | no -----+---- t | f (1 row) -- the real ones SELECT postal_code('VG1110', 'VG') AS vg, postal_code('AD500', 'AD') AS ad, postal_code('AZ 1000', 'AZ') AS az, postal_code('HT6110', 'HT') AS ht, postal_code('LC04 101', 'LC') AS lc, postal_code('LV-1001', 'LV') AS lv; vg | ad | az | ht | lc | lv ---------+--------+---------+---------+-----------+--------- VG-1110 | AD-500 | AZ-1000 | HT-6110 | LC-04 101 | LV-1001 (1 row) SELECT 'BB-BB11000'::postal_code AS bb, 'KY-KY1-1100'::postal_code AS ky; bb | ky ----------+----------- BB-11000 | KY-1-1100 (1 row) SELECT postal_code('XX1110', 'VG'); ERROR: cannot parse "XX1110" as a VG postal code SELECT remove_country_format(cc) FROM unnest(ARRAY['XG', 'XH', 'XJ']) cc; remove_country_format ----------------------- (3 rows) -- ===== Real-world examples: foreign registered offices in Companies House ==== -- The postcodes of non-UK registered offices (distinct values, no company -- names), country first. Most are fine; NULL is the right answer for the -- junk ones ("NOT APPLICABLE", a UK-style code filed under Surrey). SELECT cc, code, to_postal_code(code, cc) AS parsed FROM (VALUES ('AT', '1010'), ('BM', 'HM19'), ('CA', 'M5H 2M8'), ('CA', 'V4N 5W5'), ('GB', 'CP9 4PX'), ('GG', 'GY1 1ZX'), ('GG', 'GY1 3RH'), ('GG', 'GY1 4NA'), ('GG', 'GY4 6DY'), ('GI', 'GX11 1AA'), ('IM', 'IM1 1LB'), ('IM', 'IM1 2PT'), ('IM', 'IM1 2SD'), ('IM', 'IM2 1QB'), ('IM', 'IM2 4DF'), ('IM', 'IM8 1GB'), ('JE', 'JE1 0BD'), ('JE', 'JE1 1AD'), ('JE', 'JE1 1BX'), ('JE', 'JE1 1GL'), ('JE', 'JE1 1RB'), ('JE', 'JE1 1SG'), ('JE', 'JE1 2LH'), ('JE', 'JE1 2TR'), ('JE', 'JE2 3NY'), ('JE', 'JE2 3QA'), ('JE', 'JE2 3RA'), ('JE', 'JE4 8PW'), ('JE', 'JE4 8PX'), ('JE', 'JE4 9WG'), ('LU', '1116'), ('LU', '2411'), ('LU', '8070'), ('LU', 'L - 2226'), ('LU', 'L-1528'), ('MH', '96960'), ('US', '19808'), ('US', '23219'), ('US', '34990'), ('US', '89146'), ('VG', 'NOT APPLICABLE'), ('VG', 'VG1110') ) v(cc, code) ORDER BY cc, code COLLATE "C"; cc | code | parsed ----+----------------+------------- AT | 1010 | AT-1010 BM | HM19 | BM-HM 19 CA | M5H 2M8 | CA-M5H 2M8 CA | V4N 5W5 | CA-V4N 5W5 GB | CP9 4PX | GG | GY1 1ZX | GG-GY1 1ZX GG | GY1 3RH | GG-GY1 3RH GG | GY1 4NA | GG-GY1 4NA GG | GY4 6DY | GG-GY4 6DY GI | GX11 1AA | GI-GX11 1AA IM | IM1 1LB | IM-IM1 1LB IM | IM1 2PT | IM-IM1 2PT IM | IM1 2SD | IM-IM1 2SD IM | IM2 1QB | IM-IM2 1QB IM | IM2 4DF | IM-IM2 4DF IM | IM8 1GB | IM-IM8 1GB JE | JE1 0BD | JE-JE1 0BD JE | JE1 1AD | JE-JE1 1AD JE | JE1 1BX | JE-JE1 1BX JE | JE1 1GL | JE-JE1 1GL JE | JE1 1RB | JE-JE1 1RB JE | JE1 1SG | JE-JE1 1SG JE | JE1 2LH | JE-JE1 2LH JE | JE1 2TR | JE-JE1 2TR JE | JE2 3NY | JE-JE2 3NY JE | JE2 3QA | JE-JE2 3QA JE | JE2 3RA | JE-JE2 3RA JE | JE4 8PW | JE-JE4 8PW JE | JE4 8PX | JE-JE4 8PX JE | JE4 9WG | JE-JE4 9WG LU | 1116 | LU-L-1116 LU | 2411 | LU-L-2411 LU | 8070 | LU-L-8070 LU | L - 2226 | LU-L-2226 LU | L-1528 | LU-L-1528 MH | 96960 | MH-96960 US | 19808 | US-19808 US | 23219 | US-23219 US | 34990 | US-34990 US | 89146 | US-89146 VG | NOT APPLICABLE | VG | VG1110 | VG-1110 (42 rows) -- Spacing is tidied, nothing else: surrounding and doubled spaces, and spaces -- next to a hyphen, which is how "L - 2226" turns up in real data. SELECT t AS written, to_postal_code(t, cc) AS parsed FROM (VALUES ('LU', ' L - 2226 '), ('LU', 'L -2226'), ('US', '90210 - 1234'), ('GB', ' SW1A 1AA '), ('CA', 'k1a 0b1'), ('XX', '12345'), ('VG', ' VG 1110'), ('FR', E'75008\t')) v(cc, t); written | parsed ---------------+--------------- L - 2226 | LU-L-2226 L -2226 | LU-L-2226 90210 - 1234 | US-90210-1234 SW1A 1AA | GB-SW1A 1AA k1a 0b1 | CA-K1A 0B1 12345 | VG 1110 | VG-1110 75008 | FR-75008 (8 rows) SELECT ' us - 90210 '::postal_code AS a, E'GB-SW1A\n1AA'::postal_code AS b, 'ca - K1A 0B1'::postal_code AS c; a | b | c ----------+-------------+------------ US-90210 | GB-SW1A 1AA | CA-K1A 0B1 (1 row) SELECT lower_bound(' GB - SW1A ') AS lo, ' GB-SW1A'::postal_code % ' GB-SW ' AS matches; lo | matches ---------+--------- GB-SW1A | t (1 row) SELECT postal_code(' 90210 ', ' us '); postal_code ------------- US-90210 (1 row) SELECT 'U S-90210'::postal_code; ERROR: postal_code requires a two-letter country prefix, e.g. "US-90210" LINE 1: SELECT 'U S-90210'::postal_code; ^ HINT: got "U S-90210" SELECT 'US-90 210'::postal_code; postal_code ------------- US-90210 (1 row) -- ===== Obvious variants ====================================================== -- What a format accepts as written is taken as written. Only if that fails -- are these tried: the country's own letters dropped ("MH96960"), spaces and -- hyphens swapped ("1050 010"), spaces dropped ("06 830"). They re-spell the -- same characters, so they recognise a spelling but cannot make a non-code one. SELECT cc, written, to_postal_code(written, cc) AS parsed FROM (VALUES ('MH', 'MH96960'), ('MH', 'MH 96960'), ('MH', 'mh-96960'), ('AI', 'AI2640'), ('AI', 'AI 2640'), ('US', 'US90210'), ('US', 'US 90210-1234'), ('CA', 'CA K1A 0B1'), ('FR', 'FR 75008'), ('LU', 'L1471'), ('LU', 'L 1820'), ('LU', 'L-1 452'), ('LU', 'LU-L-1471'), ('LU', 'LU1471'), ('US', '06 830'), ('US', '19 801'), ('DE', '10 117'), ('CA', 'K1A0B1'), ('CA', 'K1A-0B1'), ('PT', '1050 010'), ('PT', '1050-010'), ('PT', '1050010'), ('GB', 'SW1A-1AA'), ('GB', 'SW1A1AA'), ('BR', '01310 100'), ('VG', 'VG 1110'), ('VG', 'VG-1110')) v(cc, written); cc | written | parsed ----+---------------+--------------- MH | MH96960 | MH-96960 MH | MH 96960 | MH-96960 MH | mh-96960 | MH-96960 AI | AI2640 | AI-2640 AI | AI 2640 | AI-2640 US | US90210 | US-90210 US | US 90210-1234 | US-90210-1234 CA | CA K1A 0B1 | CA-K1A 0B1 FR | FR 75008 | FR-75008 LU | L1471 | LU-L-1471 LU | L 1820 | LU-L-1820 LU | L-1 452 | LU-L-1452 LU | LU-L-1471 | LU-L-1471 LU | LU1471 | LU-L-1471 US | 06 830 | US-06830 US | 19 801 | US-19801 DE | 10 117 | DE-10117 CA | K1A0B1 | CA-K1A 0B1 CA | K1A-0B1 | CA-K1A 0B1 PT | 1050 010 | PT-1050-010 PT | 1050-010 | PT-1050-010 PT | 1050010 | PT-1050-010 GB | SW1A-1AA | GB-SW1A 1AA GB | SW1A1AA | GB-SW1A 1AA BR | 01310 100 | BR-01310-100 VG | VG 1110 | VG-1110 VG | VG-1110 | VG-1110 (27 rows) -- Jersey, Guernsey and the Isle of Man codes start with the country's own -- letters, so they must keep working as written and the retry must not turn -- a broken one into a valid one. SELECT cc, written, to_postal_code(written, cc) AS parsed FROM (VALUES ('IM', 'IM1 1AA'), ('JE', 'JE4 9WG'), ('GG', 'GY1 1ZX'), ('IM', 'IM1 SPT'), ('JE', 'JEL 0BD'), ('IM', 'IM1 2P'), ('JE', 'JE1 1G'), ('GG', 'GG1 1AA'), ('IM', 'IM 1AA')) v(cc, written); cc | written | parsed ----+---------+------------ IM | IM1 1AA | IM-IM1 1AA JE | JE4 9WG | JE-JE4 9WG GG | GY1 1ZX | GG-GY1 1ZX IM | IM1 SPT | JE | JEL 0BD | IM | IM1 2P | JE | JE1 1G | GG | GG1 1AA | IM | IM 1AA | (9 rows) -- ... and none of it reaches address text, which is a data-cleaning job, not a postcode one SELECT cc, written, to_postal_code(written, cc) AS parsed FROM (VALUES ('US', 'DE 19801'), ('US', 'DELAWARE 19803'), ('AU', 'NSW 2000'), ('CA', 'ON L6M 0A8'), ('PT', '1200-445 LISBON'), ('IT', 'CAP 00144'), ('IE', 'DUBLIN 2'), ('US', 'PO BOX 3085'), ('US', '9021'), ('US', '902101'), ('LU', 'L-12345')) v(cc, written); cc | written | parsed ----+-----------------+-------- US | DE 19801 | US | DELAWARE 19803 | AU | NSW 2000 | CA | ON L6M 0A8 | PT | 1200-445 LISBON | IT | CAP 00144 | IE | DUBLIN 2 | US | PO BOX 3085 | US | 9021 | US | 902101 | LU | L-12345 | (11 rows) -- the same for fragments, ranges and the operator SELECT lower_bound('US-US 90') AS a, lower_bound('LU-L 14') AS b, upper_bound('PT-1050 0') AS c; a | b | c ----------+-----------+------------- US-90000 | LU-L-1400 | PT-1050-100 (1 row) SELECT 'US-90210'::postal_code % 'US-US90' AS yes, 'US-90210'::postal_code % 'US-9 02' AS also_yes, 'US-90210'::postal_code % 'US-91' AS no; yes | also_yes | no -----+----------+---- t | t | f (1 row) -- ===== The UAE ================================================================= -- The UAE has no postal codes, but two schemes work like them. Abu Dhabi assigns -- 5-digit codes by district (20000 central Abu Dhabi, 23251 Khalifa City, 20014 Yas -- Island). Dubai numbers every building with a 10-digit Makani code, written -- NNNNN NNNNN, which is what GeoNames holds for the UAE. One format, NNNNN with an -- optional second block, holds both; the world view says what it is. PO Box numbers, -- which is what UAE addresses mostly give, are not codes and are rejected. SELECT postal_code('20000', 'AE') AS abu_dhabi, postal_code('18038 79169', 'AE') AS dubai_makani, postal_code('1803879169', 'AE') AS b, 'AE-18038-79169'::postal_code AS c; abu_dhabi | dubai_makani | b | c -----------+----------------+----------------+---------------- AE-20000 | AE-18038 79169 | AE-18038 79169 | AE-18038 79169 (1 row) SELECT written, to_postal_code(written, 'AE') AS parsed FROM (VALUES ('20000'), ('23251'), ('20014'), ('18038 79169'), ('71241'), ('450676'), ('PO BOX 413383'), ('P.O. BOX 98444'), ('1204'), ('00000'), ('18038 7916')) v(written); written | parsed ----------------+---------------- 20000 | AE-20000 23251 | AE-23251 20014 | AE-20014 18038 79169 | AE-18038 79169 71241 | AE-71241 450676 | PO BOX 413383 | P.O. BOX 98444 | 1204 | 00000 | AE-00000 18038 7916 | (11 rows) SELECT outcode('AE-18038 79169'::postal_code) AS makani_head, outcode('AE-20000'::postal_code) AS abu_dhabi_is_its_own; makani_head | abu_dhabi_is_its_own -------------+---------------------- AE-18038 | AE-20000 (1 row) SELECT iso2, format, basis, left(note, 50) AS note FROM postal_code_world WHERE iso2 IN ('AE', 'OM', 'QA'); iso2 | format | basis | note ------+---------------+-----------------+---------------------------------------------------- AE | NNNNN[ NNNNN] | see note | no postal code system, but two location schemes: A OM | NNN | Wikipedia | QA | | no postal codes | (3 rows) -- ===== Other scripts, and punctuation that is only punctuation ================ -- Real data writes digits in other scripts, Unicode dashes and spaces, Japan's -- postal mark, a Brazilian CEP without its hyphen or with dots, a ZIP+4 without -- its hyphen. All of them are the same code re-spelled. SELECT cc, what, to_postal_code(written, cc) AS parsed FROM (VALUES ('IR', 'Persian digits', '۱۱۴۱۶۱۳۶۷۵'), ('IR', 'Persian digits and hyphen', '۱۱۵۱۷-۱۳۵۱۳'), ('MM', 'Burmese digits', '၀၇၀၉၁'), ('BD', 'Bengali digits', '১২১৪'), ('IN', 'Devanagari digits', '४००००१'), ('JP', 'postal mark and U+2212 minus', '〒050−0083'), ('JP', 'U+2010 hyphen', '064‐0915'), ('JP', 'postal mark, space and en dash', '〒 100–8111'), ('JP', 'full-width digits', '100-8111'), ('US', 'full-width digits', '90210'), ('FR', 'full-width digits', '75008'), ('FR', 'ASCII', '75008'), ('FR', 'zero-width space first', '​75008'), ('LU', 'L, U+2212 minus, 2226', 'L − 2226'), ('BR', 'ASCII', '01139020'), ('BR', 'ASCII', '06.026-170'), ('BR', 'ASCII', '06026170'), ('BR', 'ASCII', '01310-100'), ('BR', 'ASCII', '0113902'), ('US', 'ASCII', '902101234'), ('US', 'ASCII', '90210 1234'), ('US', 'ASCII', '902100000'), ('US', 'ASCII', '90210-1234'), ('CO', 'ASCII', '630001-025'), ('CO', 'ASCII', '050010210'), ('CO', 'ASCII', '630001'), ('MZ', 'ASCII', '0101-01'), ('MZ', 'ASCII', '0101')) v(cc, what, written); cc | what | parsed ----+--------------------------------+---------------- IR | Persian digits | IR-11416-13675 IR | Persian digits and hyphen | IR-11517-13513 MM | Burmese digits | MM-07091 BD | Bengali digits | BD-1214 IN | Devanagari digits | IN-400001 JP | postal mark and U+2212 minus | JP-050-0083 JP | U+2010 hyphen | JP-064-0915 JP | postal mark, space and en dash | JP-100-8111 JP | full-width digits | JP-100-8111 US | full-width digits | US-90210 FR | full-width digits | FR-75008 FR | ASCII | FR-75008 FR | zero-width space first | FR-75008 LU | L, U+2212 minus, 2226 | LU-L-2226 BR | ASCII | BR-01139-020 BR | ASCII | BR-06026-170 BR | ASCII | BR-06026-170 BR | ASCII | BR-01310-100 BR | ASCII | US | ASCII | US-90210-1234 US | ASCII | US-90210-1234 US | ASCII | US | ASCII | US-90210-1234 CO | ASCII | CO-630001-025 CO | ASCII | CO-050010-210 CO | ASCII | CO-630001 MZ | ASCII | MZ-0101-01 MZ | ASCII | MZ-0101 (28 rows) SELECT 'ir-۱۱۴۱۶۱۳۶۷۵'::postal_code AS a, 'JP-〒050−0083'::postal_code AS b; a | b ----------------+------------- IR-11416-13675 | JP-050-0083 (1 row) -- Malta's postcodes are officially ASCII, but are often written with the native letters SELECT what, to_postal_code(written, 'MT') AS parsed FROM (VALUES ('Z-dot', 'ŻTN 3000'), ('H-bar', 'MLĦ 2777'), ('lower-case z-dot', 'żtn 3000'), ('ASCII', 'ZTN 3000'), ('C-dot', 'ĊSP 1000'), ('G-dot', 'ĠRB 1000'), ('3 digits', 'ĠRB 100'), ('outcode only', 'MLH')) v(what, written); what | parsed ------------------+------------- Z-dot | MT-ZTN 3000 H-bar | MT-MLH 2777 lower-case z-dot | MT-ZTN 3000 ASCII | MT-ZTN 3000 C-dot | MT-CSP 1000 G-dot | MT-GRB 1000 3 digits | outcode only | MT-MLH (8 rows) -- a GB fragment may end part-way through the unit: "M14 6Q" is a prefix of "M14 6QA".."M14 6QZ" SELECT lower_bound('GB-M14 6Q') AS lo, upper_bound('GB-M14 6Q') AS hi; lo | hi ------------+------------ GB-M14 6QA | GB-M14 6RA (1 row) SELECT lower_bound('GB-M14 6Z') AS lo, upper_bound('GB-M14 6Z') AS hi; lo | hi ------------+------------ GB-M14 6ZA | GB-M14 7AA (1 row) SELECT 'GB-M14 6QA'::postal_code % 'GB-M14 6Q' AS yes, 'GB-M14 6RA'::postal_code % 'GB-M14 6Q' AS no, 'GB-M14 6QZ'::postal_code <@ postal_prefix('GB-M14 6Q') AS inside, 'GB-M14 6RA'::postal_code <@ postal_prefix('GB-M14 6Q') AS outside; yes | no | inside | outside -----+----+--------+--------- t | f | t | f (1 row) -- (the 20 letters a unit may end in: C I K M O V never occur) SELECT count(*) AS units_in_m14_6q FROM (SELECT to_postal_code('M14 6' || chr(64 + a) || chr(64 + b), 'GB') AS pc FROM generate_series(1, 26) a, generate_series(1, 26) b) x WHERE pc <@ postal_prefix('GB-M14 6Q'); units_in_m14_6q ----------------- 20 (1 row) -- ===== A leading letter: Argentina ============================================ -- NNNN, the province letter + 4 digits, and the full 8-character CPA are all written. -- /([A-HJ-NP-Z]\d{4}([A-Z]{3})?|\d{4})/ (the province letters exclude I and O): the leading letter is optional, so codes -- without it sort first, and the three trailing letters only go with a province letter -- "1832GMR" is no code. SELECT written, to_postal_code(written, 'AR') AS parsed FROM (VALUES ('1832'), ('B1832'), ('B1832GMR'), ('b1832gmr'), ('B 1832'), ('B-1832'), ('1832GMR'), ('BB1832'), ('B183'), ('B1832GM'), ('')) v(written); written | parsed ----------+------------- 1832 | AR-1832 B1832 | AR-B1832 B1832GMR | AR-B1832GMR b1832gmr | AR-B1832GMR B 1832 | AR-B1832 B-1832 | 1832GMR | BB1832 | B183 | B1832GM | | (11 rows) SELECT pc FROM (VALUES ('AR-B1832GMR'), ('AR-1832'), ('AR-C1425'), ('AR-B1832'), ('AR-9000'), ('AR-A4190'), ('AR-B1832AAA'), ('AR-1000')) v(t), LATERAL (SELECT t::postal_code AS pc) x ORDER BY pc; pc ------------- AR-1000 AR-1832 AR-9000 AR-A4190 AR-B1832 AR-B1832AAA AR-B1832GMR AR-C1425 (8 rows) SELECT outcode('AR-B1832GMR'::postal_code) AS outcode, outcode('AR-1832'::postal_code) AS none_to_drop; outcode | none_to_drop ----------+-------------- AR-B1832 | AR-1832 (1 row) SELECT lower_bound('AR-B') AS lo, upper_bound('AR-B') AS hi; lo | hi ----------+---------- AR-B0000 | AR-C0000 (1 row) SELECT lower_bound('AR-B18') AS lo, upper_bound('AR-B18') AS hi; lo | hi ----------+---------- AR-B1800 | AR-B1900 (1 row) SELECT lower_bound('AR-B1832G') AS lo, upper_bound('AR-B1832G') AS hi; lo | hi -------------+------------- AR-B1832GAA | AR-B1832HAA (1 row) SELECT lower_bound('AR-18') AS lo, upper_bound('AR-18') AS hi; lo | hi ---------+--------- AR-1800 | AR-1900 (1 row) SELECT lower_bound('AR-Z') AS lo, upper_bound('AR-Z') AS hi_is_the_end_of_the_country; lo | hi_is_the_end_of_the_country ----------+------------------------------ AR-Z0000 | AR-~ (1 row) SELECT 'AR-B1832GMR'::postal_code % 'AR-B' AS yes, 'AR-1832'::postal_code % 'AR-B' AS no_digits_only, 'AR-B1832GMR'::postal_code % 'AR-B1832G' AS yes2, 'AR-C1425'::postal_code % 'AR-B' AS no_other_province; yes | no_digits_only | yes2 | no_other_province -----+----------------+------+------------------- t | f | t | f (1 row) -- ===== pg_dump =============================================================== -- pg_dump copies each configuration table with the condition the extension -- registered, and runs with an EMPTY search_path. A condition that names a table -- unqualified fails there ("relation does not exist") and makes pg_dump fail for the -- whole database -- so run every condition the way pg_dump does. DO $$ DECLARE r record; n int := 0; BEGIN SET LOCAL search_path = ''; FOR r IN SELECT u.cfg::regclass::text AS tbl, u.cond FROM pg_extension e, unnest(e.extconfig, e.extcondition) AS u(cfg, cond) WHERE e.extname = 'postcode' LOOP EXECUTE format('SELECT count(*) FROM %s %s', r.tbl, coalesce(r.cond, '')); n := n + 1; END LOOP; RAISE NOTICE '% configuration tables dump cleanly with an empty search_path', n; END $$; NOTICE: 2 configuration tables dump cleanly with an empty search_path SELECT u.cfg::regclass AS table, coalesce(u.cond, '') AS dumped_where FROM pg_extension e, unnest(e.extconfig, e.extcondition) AS u(cfg, cond) WHERE e.extname = 'postcode' ORDER BY u.cfg::regclass::text; table | dumped_where ----------------------------+------------------- postal_code_languages | WHERE NOT builtin postal_code_user_countries | (2 rows) -- ===== Fragments that end in a separator, or are just a country's own marker ==== -- Every text prefix of a code is a fragment, including one that stops right after the hyphen of -- a ZIP+4 or CEP -- text that starts with the hyphen, so the ZIP+4 / suffixed codes under it but -- not the bare ZIP5 / base itself. For Luxembourg, "L-" is every code. SELECT lower_bound('US-90210-') AS lo, upper_bound('US-90210-') AS hi, 'US-90210'::postal_code <@ postal_prefix('US-90210-') AS bare_zip_inside, 'US-90210-0001'::postal_code <@ postal_prefix('US-90210-') AS zip4_inside; lo | hi | bare_zip_inside | zip4_inside ---------------+----------+-----------------+------------- US-90210-0001 | US-90211 | f | t (1 row) SELECT lower_bound('US-99999-') AS lo, upper_bound('US-99999-') AS hi_is_the_end_of_the_country; lo | hi_is_the_end_of_the_country ---------------+------------------------------ US-99999-0001 | US-~ (1 row) SELECT lower_bound('BR-01310-') AS lo, upper_bound('BR-01310-') AS hi, 'BR-01310'::postal_code <@ postal_prefix('BR-01310-') AS bare_base_inside, 'BR-01310-000'::postal_code <@ postal_prefix('BR-01310-') AS suffix_000_inside; lo | hi | bare_base_inside | suffix_000_inside --------------+----------+------------------+------------------- BR-01310-000 | BR-01311 | f | t (1 row) SELECT lower_bound('LU-L') AS lo, upper_bound('LU-L') AS hi_is_the_end_of_the_country, lower_bound('LU-L-') = lower_bound('LU-L') AS same; lo | hi_is_the_end_of_the_country | same -----------+------------------------------+------ LU-L-0000 | LU-~ | t (1 row) SELECT 'US-90210-1234'::postal_code % 'US-90210-' AS yes, 'US-90210'::postal_code % 'US-90210-' AS bare_is_not, 'US-90211-1234'::postal_code % 'US-90210-' AS no, 'LU-L-1311'::postal_code % 'LU-L-' AS lu_yes, 'LU-1311'::postal_code % 'LU-L' AS lu_bare_yes; yes | bare_is_not | no | lu_yes | lu_bare_yes -----+-------------+----+--------+------------- t | f | f | t | t (1 row) -- ===== to_postal_prefix(): postal_prefix() that returns NULL instead of raising =========== SELECT to_postal_prefix('GB-LS24') AS ok, to_postal_prefix('GB-LS24') = postal_prefix('GB-LS24') AS same_as_postal_prefix; ok | same_as_postal_prefix -------------------+----------------------- [GB-LS24,GB-LS25) | t (1 row) SELECT t AS text, to_postal_prefix(t) AS fragment FROM (VALUES ('US-902'), ('CA-K1A 0'), ('FR-75'), ('XX-12'), ('90210'), ('US-9A'), ('GB-A'), ('LU-L-'), (' us - 90 ')) v(t); text | fragment -----------+----------------------------- US-902 | [US-90200,US-90300) CA-K1A 0 | ["CA-K1A 0A0","CA-K1A 1A0") FR-75 | [FR-75000,FR-76000) XX-12 | 90210 | US-9A | GB-A | LU-L- | [LU-L-0000,LU-~) us - 90 | [US-90000,US-91000) (9 rows) SELECT count(*) AS valid_fragments FROM (VALUES ('US-9'), ('US-9x'), ('FR-75'), ('ZZ-1')) v(t) WHERE to_postal_prefix(t) IS NOT NULL; valid_fragments ----------------- 2 (1 row) -- ===== The UK's letter rules =================================================== -- Royal Mail never uses C I K M O V in a unit, only A-H J K P S-U W after the digit of an A9A -- outcode (W1A, N1C ...), and only A B E H M N P R V-Y after the digit of an AA9A one (EC1A, SW1P ...). -- The `postcode` type has always been lenient about these; postal_code's GB format enforces them, so a -- typo like NG12 4FO (letter O for zero, which really occurs) is not stored. SELECT code, to_postal_code(code, 'GB') AS parsed, why FROM (VALUES ('SW1A 1AA', 'real'), ('NG12 4FO', 'unit letter O'), ('SW1A 1CC', 'unit letter C'), ('SW1A 1AI', 'unit letter I'), ('SW1A 1MK', 'unit letters M K'), ('SW1A 1VV', 'unit letter V'), ('SW1A 1ZZ', 'Z is fine'), ('SW1A 1QX', 'Q X are fine'), ('W1A 1AA', 'A9A third letter A'), ('N1C 4AG', 'A9A third letter C'), ('E1W 1AA', 'A9A third letter W'), ('W1I 1AA', 'A9A third letter I'), ('W1Z 1AA', 'A9A third letter Z'), ('EC1A 1BB', 'AA9A fourth letter A'), ('EC1Y 8SY', 'AA9A fourth letter Y'), ('EC1C 1BB', 'AA9A fourth letter C'), ('SW1Z 1AA', 'AA9A fourth letter Z'), ('SW1D 1AA', 'AA9A fourth letter D')) v(code, why); code | parsed | why ----------+-------------+---------------------- SW1A 1AA | GB-SW1A 1AA | real NG12 4FO | | unit letter O SW1A 1CC | | unit letter C SW1A 1AI | | unit letter I SW1A 1MK | | unit letters M K SW1A 1VV | | unit letter V SW1A 1ZZ | GB-SW1A 1ZZ | Z is fine SW1A 1QX | GB-SW1A 1QX | Q X are fine W1A 1AA | GB-W1A 1AA | A9A third letter A N1C 4AG | GB-N1C 4AG | A9A third letter C E1W 1AA | GB-E1W 1AA | A9A third letter W W1I 1AA | | A9A third letter I W1Z 1AA | | A9A third letter Z EC1A 1BB | GB-EC1A 1BB | AA9A fourth letter A EC1Y 8SY | GB-EC1Y 8SY | AA9A fourth letter Y EC1C 1BB | | AA9A fourth letter C SW1Z 1AA | | AA9A fourth letter Z SW1D 1AA | | AA9A fourth letter D (18 rows) SELECT outcode, to_postal_code(outcode, 'GB') AS parsed FROM (VALUES ('W1C'), ('W1I'), ('EC1M'), ('EC1I'), ('LS1'), ('LS24'), ('LS1Z'), ('B1'), ('B99')) v(outcode); outcode | parsed ---------+--------- W1C | GB-W1C W1I | EC1M | GB-EC1M EC1I | LS1 | GB-LS1 LS24 | GB-LS24 LS1Z | B1 | GB-B1 B99 | GB-B99 (9 rows) -- bounds skip the letters that can't occur, so they are still real codes SELECT lower_bound('GB-SW1A 1B') AS lo, upper_bound('GB-SW1A 1B') AS hi; lo | hi -------------+------------- GB-SW1A 1BA | GB-SW1A 1DA (1 row) SELECT lower_bound('GB-SW1A 1Z') AS lo, upper_bound('GB-SW1A 1Z') AS hi; lo | hi -------------+------------- GB-SW1A 1ZA | GB-SW1A 2AA (1 row) SELECT lower_bound('GB-W1H') AS lo, upper_bound('GB-W1H') AS hi; lo | hi --------+-------- GB-W1H | GB-W1J (1 row) SELECT lower_bound('GB-LS1B') AS lo, upper_bound('GB-LS1B') AS hi; lo | hi ---------+--------- GB-LS1B | GB-LS1E (1 row) SELECT lower_bound('GB-LS1Y') AS lo, upper_bound('GB-LS1Y') AS hi; lo | hi ---------+-------- GB-LS1Y | GB-LS2 (1 row) SELECT to_postal_prefix('GB-SW1A 1C') AS no_unit_starts_with_c, to_postal_prefix('GB-SW1A 1D') IS NOT NULL AS d_is_fine; no_unit_starts_with_c | d_is_fine -----------------------+----------- | t (1 row) -- every unit under a sector, counted by range, equals the units that exist (20 letters x 20) SELECT count(*) AS units_in_sw1a_1 FROM (SELECT to_postal_code('SW1A 1' || chr(64 + a) || chr(64 + b), 'GB') AS pc FROM generate_series(1, 26) a, generate_series(1, 26) b) x WHERE pc <@ postal_prefix('GB-SW1A 1'); units_in_sw1a_1 ----------------- 400 (1 row) SELECT 'NG12 4FO'::postcode AS the_postcode_type_stays_lenient; the_postcode_type_stays_lenient --------------------------------- NG12 4FO (1 row) -- ===== Built-in and user assignments ========================================= -- What ships with the extension is separate from what users assign, so a dump -- can carry exactly the latter. The view shows both; a user row wins. SELECT iso2, format_name, builtin FROM postal_code_country_formats WHERE iso2 IN ('US', 'GB', 'GG', 'IE') ORDER BY iso2; iso2 | format_name | builtin ------+-------------+--------- GB | GB | t GG | GB | t IE | IE | t US | US | t (4 rows) BEGIN; SELECT add_country_format('GG', 'FR'); -- override a built-in assignment add_country_format -------------------- (1 row) SELECT iso2, format_name, builtin FROM postal_code_country_formats WHERE iso2 = 'GG'; iso2 | format_name | builtin ------+-------------+--------- GG | FR | f (1 row) SELECT remove_country_format('IE'); -- removing a built-in one leaves a tombstone remove_country_format ----------------------- (1 row) SELECT count(*) AS ie_rows FROM postal_code_country_formats WHERE iso2 = 'IE'; ie_rows --------- 0 (1 row) SAVEPOINT s; SELECT 'IE-D02'::postal_code; ERROR: "IE" is not a supported country code LINE 1: SELECT 'IE-D02'::postal_code; ^ HINT: see postal_code_country_formats, or add one with add_country_format() ROLLBACK TO s; SELECT iso2, format_name FROM postal_code_user_countries WHERE iso2 IN ('GG', 'IE') ORDER BY iso2; iso2 | format_name ------+------------- GG | FR IE | (2 rows) SELECT add_country_format('IE', 'IE'); -- and it can be put back add_country_format -------------------- (1 row) SELECT 'IE-D02'::postal_code; postal_code ------------- IE-D02 (1 row) SELECT remove_country_format('GG'); remove_country_format ----------------------- (1 row) SELECT remove_country_format('GG'); -- (removing twice is harmless) remove_country_format ----------------------- (1 row) ROLLBACK; SELECT iso2, format_name, builtin FROM postal_code_country_formats WHERE iso2 IN ('GG', 'IE') ORDER BY iso2; iso2 | format_name | builtin ------+-------------+--------- GG | GB | t IE | IE | t (2 rows) -- ===== outcode() and district() ============================================== -- The area part of a postcode, as a complete valid postcode of its own. -- district() is the same function under its other name. SELECT outcode('GB-SW1A 1AA') AS gb, outcode('US-90210-1234') AS us, outcode('CA-K1A 0B1') AS ca, outcode('IE-A65 F4E2') AS ie, outcode('BR-01310-100') AS br; gb | us | ca | ie | br ---------+----------+--------+--------+---------- GB-SW1A | US-90210 | CA-K1A | IE-A65 | BR-01310 (1 row) SELECT district('GB-LS24 9JT') AS district, district('GB-LS24 9JT') = outcode('GB-LS24 9JT') AS same_function; district | same_function ----------+--------------- GB-LS24 | t (1 row) -- idempotent: an outcode is its own outcode SELECT outcode('GB-SW1A') AS gb, outcode(outcode('IE-A65 F4E2')) AS ie, outcode('US-90210') AS us; gb | ie | us ---------+--------+---------- GB-SW1A | IE-A65 | US-90210 (1 row) -- NULL where the leading digits are only implicitly an outcode (FR, CZ, LU), -- and for the end-of-country bound, which is not a postcode SELECT outcode('FR-75001') IS NULL AS fr, outcode('CZ-110 00') IS NULL AS cz, outcode('LU-L-1311') IS NULL AS lu, outcode('US-~') IS NULL AS end_of_country_bound; fr | cz | lu | end_of_country_bound ----+----+----+---------------------- t | t | t | t (1 row) -- full postcodes group by their outcode CREATE TEMP TABLE full_codes (pc postal_code); INSERT INTO full_codes VALUES ('GB-SW1A 1AA'), ('GB-SW1A 2AB'), ('GB-SW1B 1AA'), ('GB-LS1 4AP'), ('US-90210-1234'), ('US-90210-5678'), ('US-90211'), ('CA-K1A 0B1'), ('CA-K1A 0B2'), ('CA-K1B 0A1'), ('IE-A65 F4E2'), ('IE-A65 TV22'), ('FR-75001'), ('FR-75002'); SELECT outcode(pc) AS outcode, count(*) FROM full_codes GROUP BY 1 ORDER BY 1 NULLS LAST; outcode | count ----------+------- CA-K1A | 2 CA-K1B | 1 GB-LS1 | 1 GB-SW1A | 2 GB-SW1B | 1 IE-A65 | 2 US-90210 | 2 US-90211 | 1 | 2 (9 rows) -- properties that must hold for every value in the fixture SELECT count(*) FILTER (WHERE outcode(pc) IS NOT NULL) AS with_outcode, count(*) FILTER (WHERE outcode(pc) IS NULL) AS without, count(*) FILTER (WHERE outcode(pc) > pc) AS outcode_sorts_after_its_code, count(*) FILTER (WHERE outcode(outcode(pc)) IS DISTINCT FROM outcode(pc)) AS not_idempotent, count(*) FILTER (WHERE outcode(pc) IS NOT NULL AND NOT (pc <@ postal_prefix(outcode(pc)::text))) AS not_in_its_outcodes_range, count(*) FILTER (WHERE outcode(pc) IS NOT NULL AND outcode(pc) <> lower_bound(outcode(pc)::text)) AS outcode_not_first_of_its_range FROM (SELECT pc FROM addr UNION ALL SELECT pc FROM full_codes) all_codes; with_outcode | without | outcode_sorts_after_its_code | not_idempotent | not_in_its_outcodes_range | outcode_not_first_of_its_range --------------+---------+------------------------------+----------------+---------------------------+-------------------------------- 48 | 9 | 0 | 0 | 0 | 0 (1 row) -- IMMUTABLE (it only reads the value's own bits, never the country table), so -- unlike postal_code(...) it can be indexed CREATE INDEX addr_outcode_idx ON addr (outcode(pc)); SET enable_seqscan = off; SET enable_bitmapscan = off; SELECT pg_temp.uses_index_cond($q$ SELECT * FROM addr WHERE outcode(pc) = 'GB-PH26' $q$) AS outcode_expression_index_used; outcode_expression_index_used ------------------------------- t (1 row) RESET enable_seqscan; RESET enable_bitmapscan; DROP INDEX addr_outcode_idx; -- THE SAFETY NET. Folding postal_prefix() into a plan would let a cached -- plan keep a range computed under a country assignment that has since -- changed, so the folded plan depends on postal_code_country_formats and a -- trigger invalidates it whenever that table changes. Austria is assigned the -- plain-5-digit format, a row is stored, a statement is prepared (its plan is -- cached); then Country XW is reassigned to the Czech format, which renders with -- a space. The same prepared statement must now mean the CZ range -- and so -- return the CZ-format row, not the old FR-format one. SELECT add_country_format('XW', 'FR'); add_country_format -------------------- (1 row) INSERT INTO addr (pc) VALUES ('XW-12345'); PREPARE xw_prefix AS SELECT pc FROM addr WHERE pc <@ postal_prefix('XW-12'); EXECUTE xw_prefix; pc ---------- XW-12345 (1 row) EXECUTE xw_prefix; pc ---------- XW-12345 (1 row) SELECT add_country_format('XW', 'CZ'); add_country_format -------------------- (1 row) INSERT INTO addr (pc) VALUES ('XW-12345'); EXECUTE xw_prefix; pc ----------- XW-123 45 (1 row) DEALLOCATE xw_prefix; SELECT remove_country_format('XW'); remove_country_format ----------------------- (1 row) DELETE FROM addr WHERE pc::text LIKE 'XW-%'; -- Binary send/recv (postal_code_recv/postal_code_send, the -- COPY ... WITH (FORMAT binary) path) is deliberately not covered -- here: doing that portably needs a checked-in binary fixture file -- and the same @abs_srcdir@ substitution machinery the "binary" -- regression test above already has to work around (see this -- Makefile's own comment on sql/binary.sql) -- not worth duplicating -- for a second type in this pass. Verified manually instead: a full -- COPY ... WITH (FORMAT binary) round trip of all 43,147 real -- US+CA GEONAMES rows (out to a file and back in) matched the -- original data exactly.