Census Identifiers Explained: GEOIDs, Codes and the Leading Zero Problem

Problem statement

A census GEOID looks like a number and is not one. 06037207400 is three fixed-width codes glued together โ€” 06 for California, 037 for Los Angeles County, 207400 for a tract โ€” and the leading zero is part of the first code, not padding.

Store it as an integer and the zero disappears, the width changes from 11 to 10, and the identifier stops matching anything. This is not a rare edge case. Reading the Census Bureau's national tract gazetteer with pandas 3.0.5 defaults:

  • 15,662 of 85,396 tract GEOIDs (18.3%) come back one digit short. Every tract in Alabama, Alaska, Arizona, Arkansas, California, Colorado and Connecticut.
  • A join to the 2023 tract boundaries then matches 69,424 polygons instead of 85,060. California's 9,109 tracts are among the rows that vanish.
  • Nothing warns you. The column simply arrives as int64, and the one loud error pandas does raise is the one people silence by converting both sides to text.

Identifiers are also written in several forms across census products โ€” GEOID, GEO_ID, GEOIDFQ, GEOID20 โ€” and treating any of them as a number, or confusing one form for another, breaks joins in the same quiet way. The same trap exists for any code system with leading zeros; statistical codes that start with a letter, like the ONS's, avoid it.

Quick answer

Read every identifier column as text, at the moment of reading:

import pandas as pd

naive = pd.read_csv("2023_Gaz_tracts_national.zip", sep="\t")
safe = pd.read_csv("2023_Gaz_tracts_national.zip", sep="\t", dtype={"GEOID": str})

print(naive.GEOID.dtype, naive.GEOID.iloc[0])
print(safe.GEOID.dtype, safe.GEOID.iloc[0])
changed = naive.GEOID.astype(str) != safe.GEOID
print(f"{changed.sum():,} of {len(safe):,} tract GEOIDs lost a leading zero")
int64 1001020100
str 01001020100
15,662 of 85,396 tract GEOIDs lost a leading zero

Then check the width. Every tract GEOID is 11 characters; a column of tract ids containing 10-character values has already been damaged, and str.zfill(11) repairs it.

Table breaking a 15-digit census block GEOID into state, county, tract, block group and block segments with their widths.
Every prefix of a valid GEOID is itself a valid GEOID one level up โ€” which only holds while the zeros are kept.

Step-by-step solution

1. Read a GEOID as fixed-width fields

geoid = "06037207400"
state, county, tract = geoid[:2], geoid[2:5], geoid[5:]
print(state, county, tract, geoid in set(safe.GEOID))
06 037 207400 True

A 2020 Delaware block from the TIGER/Line block file shows every segment:

Segment Width Cumulative length Example
State (FIPS) 2 2 10
County 3 5 10003
Tract 6 11 10003011700
Block group 1 12 100030117003
Block 4, starting with the block group digit 15 100030117003015

The length of a GEOID therefore tells you its level, and slicing it gives you every parent. The tract segment has an implied decimal point: code 207400 is "Census Tract 2074" in the name column, and 020100 is "Census Tract 201". Names are for people; only the full code is unique.

2. Recognise the other forms of the same identifier

Different Census products spell the same tract differently:

import geopandas as gpd

acs = pd.read_csv("acsdt5y2023-b01003.dat", sep="|", dtype=str)
print(acs[acs.GEO_ID.str.startswith("1400000US")].GEO_ID.iloc[0])

tiger = gpd.read_file("tl_2023_10_tract.zip", columns=["GEOID", "GEOIDFQ"], ignore_geometry=True)
print(tiger.iloc[0].tolist())
1400000US01001020100
['10001040204', '1400000US10001040204']
  • GEOID โ€” the bare code, used in boundary files.
  • GEO_ID โ€” the code prefixed with a three-digit summary level, a two-character variant, a two-character component and US. The ACS summary files and data.census.gov use it, because one table holds many levels and components.
  • GEOIDFQ โ€” the "fully qualified" GEOID added to the 2023 TIGER/Line files. It is identical to GEO_ID, so tables and boundaries can be joined without any string surgery.
  • GEOID20 โ€” the same bare code in files tied to the 2020 census, with the vintage in the column name.

3. Know which rows are at risk

print(safe.loc[changed, "USPS"].value_counts().to_dict())
counties = pd.read_csv("2023_Gaz_counties_national.zip", sep="\t")
print((counties.GEOID.astype(str).str.len() == 4).sum(), "of", len(counties),
      "county GEOIDs came back with 4 digits")
{'CA': 9129, 'AZ': 1765, 'CO': 1447, 'AL': 1437, 'CT': 884, 'AR': 823, 'AK': 177}
318 of 3222 county GEOIDs came back with 4 digits

State FIPS codes are assigned alphabetically from 01, so the states hit are exactly those with codes below 10. They include California, the state with the most tracts, which is why the damage is 18.3% of rows rather than a handful. ZIP Code Tabulation Areas have the same exposure: 2,577 of 33,774 ZCTA codes begin with 0.

4. Watch for the float route

A single blank cell changes the failure:

import io

csv_text = "GEOID,population\n01001020100,1775\n,12\n01001020200,2055\n"
gappy = pd.read_csv(io.StringIO(csv_text))
print(gappy.GEOID.dtype, gappy.GEOID.astype(str).tolist())
print(gappy.GEOID.astype(str).str.zfill(11).tolist())
print(gappy.GEOID.astype("Int64").astype(str).str.zfill(11).tolist())
float64 ['1001020100.0', nan, '1001020200.0']
['1001020100.0', nan, '1001020200.0']
['01001020100', nan, '01001020200']

An integer column with a missing value becomes float64, and its text form gains .0. That string is already 12 characters, so zfill(11) does nothing at all. Converting to the nullable Int64 first is what makes the repair work.

5. See what a numeric id does to a join

cb = gpd.read_file("cb_2023_us_tract_500k.zip", columns=["GEOID"], ignore_geometry=True)
wrong = cb.merge(naive.assign(GEOID=naive.GEOID.astype(str))[["GEOID"]], on="GEOID")
right = cb.merge(safe[["GEOID"]], on="GEOID")
print(f"{len(cb):,} polygons: {len(right):,} match text ids, {len(wrong):,} match naive ids")
85,186 polygons: 85,060 match text ids, 69,424 match naive ids

Merging the integer column directly does fail loudly:

ValueError: You are trying to merge on str and int64 columns for key 'GEOID'. If you wish to proceed you should use pd.concat

The silent version is what follows when that error is "fixed" with astype(str) on the numeric side: the merge is now legal, and 15,636 more polygons fail to match than with the correctly read ids.

6. Compare codes that were designed to avoid it

ONS statistical codes for England and Wales start with a letter and have a fixed width of nine characters. From the ONS 2021 lookup, the first Output Area is E00000001, in LSOA E01000001, MSOA E02000001 and local authority E09000001. The E00/E01/E02 prefix also encodes the level, as length does for GEOIDs. No CSV reader will turn one into a number.

EU NUTS codes work the same way โ€” a two-letter country code plus one character per level, as in DE300. Local administrative units are coded nationally, and purely numeric national codes, such as French commune codes that begin 01, carry the same leading-zero risk as a FIPS code.

Grid showing the same tract written as GEOID, GEO_ID, GEOIDFQ and a tract name, with which Census product uses each form.
Join on the form both files share: GEOIDFQ to GEO_ID, or GEOID to the GEO_ID suffix.

Code examples

Example 1 โ€” describe a column of GEOIDs by width

WIDTHS = {2: "state", 5: "county", 11: "tract", 12: "block group", 15: "block"}


def describe_geoids(ids):
    """Report what level each id is, judged by its length, and flag the rest."""
    ids = pd.Series(ids, dtype="string").str.strip()
    lengths = ids.str.len()
    levels = lengths.map(WIDTHS).fillna("unknown")
    non_digit = ~ids.str.fullmatch(r"\d+").fillna(False)
    print(levels.value_counts().to_dict())
    print(f"non-numeric ids: {int(non_digit.sum())}, missing: {int(ids.isna().sum())}")
    return pd.DataFrame({"geoid": ids, "level": levels,
                         "state": ids.str[:2], "county": ids.str[2:5]})

On the naively read and the correctly read tract column:

{'tract': 69734, 'unknown': 15662}
non-numeric ids: 0, missing: 0
{'tract': 85396}
non-numeric ids: 0, missing: 0

A column that should be one level and reports two is damaged. Run it on every identifier column as it enters a pipeline.

Example 2 โ€” repair ids that were read as numbers

def repair_geoids(values, width):
    """Restore leading zeros to ids that were read as numbers."""
    s = pd.Series(values)
    if pd.api.types.is_float_dtype(s):
        s = s.astype("Int64")                   # 1001020100.0 -> 1001020100
    s = s.astype("string").str.strip()
    s = s.str.replace(r"\.0$", "", regex=True)  # ids already turned into text via float
    fixed = s.str.zfill(width)
    too_long = int((fixed.str.len() > width).sum())
    if too_long:
        raise ValueError(f"{too_long} ids are longer than {width} characters")
    changed = int((fixed != s).sum())
    print(f"restored leading zeros on {changed:,} of {len(s):,} ids")
    return fixed
restored leading zeros on 15,662 of 85,396 ids
matches the text read: True
restored leading zeros on 2 of 3 ids
['01001020100', <NA>, '01001020200']
restored leading zeros on 2 of 2 ids
['01001020100', '01001020200']

The three calls are the integer column, the float column with a blank, and ids that were already written out as "1001020100.0". The width check matters: padding is only a repair when you know the true width, and an id longer than it is a different problem.

Example 3 โ€” split a GEO_ID into its parts

def geoid_from_geo_id(geo_id):
    """GEO_ID '1400000US01001020100' -> summary level, variant, component and GEOID."""
    geo_id = pd.Series(geo_id, dtype="string")
    head, geoid = geo_id.str.split("US", n=1, expand=True).T.values
    head = pd.Series(head, dtype="string")
    return pd.DataFrame({"sumlevel": head.str[:3], "variant": head.str[3:5],
                         "component": head.str[5:7], "geoid": pd.Series(geoid, dtype="string")})
  sumlevel variant component         geoid
0      860      Z2        00         00601
1      150      00        00  010010201001
2      040      00        00            06

Filter on both the summary level and a component of 00 before joining. Across the whole B01003 file that leaves exactly 85,381 tract rows; skipping the component test lets in partial geographies such as the metropolitan part of a state.

Explanation

Why census codes have leading zeros

They are fixed-width codes from a standard, not counts. State FIPS codes run alphabetically from 01 for Alabama, with a few numbers such as 03 and 07 unused, so the first seven states in the alphabet have a zero in front. County codes are three digits within each state โ€” 97.5% of them odd numbers โ€” and the smallest ones, 001 to 099, carry zeros of their own. Width is part of the meaning: 06 plus 037 is only unambiguous because each part has a known length.

Why pandas reads them as integers

read_csv infers types. A column in which every value parses as an integer becomes int64, and the leading zero is not part of any integer. pandas 3 made text columns default to its str dtype, but that changes nothing here, because a column of digits is not text to the parser โ€” measured, the GEOID column still arrived as int64 in pandas 3.0.5.

A shapefile stores field types, so the 2023 cartographic tract file read with GeoPandas kept GEOID as text. The damage almost always enters through a CSV, a spreadsheet, or a table typed by hand.

Why the failure is silent

pandas refuses to merge text keys with integer keys, which would be a safe outcome if people did not immediately convert one side to make the error go away. After that, an inner join drops 18.5% of the boundary rows without complaint, and a left join keeps them with empty values.

On a choropleth, a left-join failure looks like a data gap in seven states โ€” plausible enough to ship. The row-count check before and after the join is the only reliable alarm.

Why the same tract has several identifiers

A boundary file holds one level per file, so the bare GEOID is enough. A table file holds every summary level and every geographic component together, so each row needs GEO_ID's prefix to say which one it is. The 2023 TIGER/Line files added GEOIDFQ to carry the table form alongside the bare one, so the two kinds of file can be joined directly.

Bar chart of tract GEOIDs that lost a leading zero by state: California 9,129, Arizona 1,765, Colorado 1,447, Alabama 1,437, Connecticut 884, Arkansas 823 and Alaska 177.
Because FIPS codes are alphabetical, the damage lands on whole states โ€” including the one with the most tracts.

Edge cases or notes

  • Spreadsheets strip zeros on open. A CSV that passed through a spreadsheet may already be damaged before pandas reads it; dtype=str cannot restore what is gone, but a width-aware zfill can.
  • Block GEOIDs fit in an integer and still break. Fifteen digits are exact even in float64, so no precision is lost โ€” only the leading zero.
  • Width is the validation. Tract 11, block group 12, block 15; anything else is damaged or mislabelled.
  • Tract names are not identifiers. "Census Tract 201" appears in many counties.
  • Column names carry vintages. GEOID20 in 2020 block files, GEOID in 2023 tract files; renaming them to one name hides which vintage a table is.
  • The Census API returns geography as separate text columns. state, county and tract arrive as strings and must be concatenated to form a GEOID.
  • Match the whole summary-level prefix. 0400000US gives 52 states; 040 alone gives 603 rows.
  • Letter-prefixed codes are immune. ONS codes such as E01000001 cannot be read as numbers.

FAQ

What is a census GEOID?

A fixed-width code that identifies a census area by concatenating the codes of its parents: two digits of state, three of county, six of tract, one of block group and four of block. Its length tells you its level.

Why did my GEOIDs lose their leading zero?

Because they were read as numbers. Reading the national tract gazetteer without a dtype turned 15,662 of 85,396 GEOIDs into 10-digit integers; pass dtype={"GEOID": str} when reading.

How do I add the leading zeros back?

Convert to text and pad to the known width with str.zfill, for example 11 for tracts. If the column became a float because of blank cells, convert it to the nullable Int64 type first, or the .0 suffix defeats the padding.

What is the difference between GEOID and GEO_ID?

GEOID is the bare code used in boundary files. GEO_ID prefixes it with a summary level, variant and component, as in 1400000US01001020100, because table files hold many levels at once; the 2023 TIGER/Line field GEOIDFQ matches it exactly.

Which states are affected by the leading zero problem?

Those with FIPS codes below 10: Alabama, Alaska, Arizona, Arkansas, California, Colorado and Connecticut. Together they hold 18.3% of all tracts.

Do UK census codes have the same problem?

No. ONS codes such as E00000001 for an Output Area begin with a letter, so no reader treats them as numbers, and the prefix encodes the level.