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.
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 andUS. 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 toGEO_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.
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.
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=strcannot restore what is gone, but a width-awarezfillcan. - 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.
GEOID20in 2020 block files,GEOIDin 2023 tract files; renaming them to one name hides which vintage a table is. - The Census API returns geography as separate text columns.
state,countyandtractarrive as strings and must be concatenated to form a GEOID. - Match the whole summary-level prefix.
0400000USgives 52 states;040alone gives 603 rows. - Letter-prefixed codes are immune. ONS codes such as
E01000001cannot be read as numbers.
Internal links
- Census geographies explained: blocks, tracts, output areas and why they nest โ the hierarchy these codes encode
- Fixing census joins that fail because leading zeros were dropped โ the repair, step by step
- How to join a census table to its boundaries without losing rows โ the join, with every unmatched row accounted for
- How to download census tables from an API in Python โ where GEO_ID and split geography columns come from
- Fixing census data that does not match the boundary file year โ when the codes are right but the vintage is not
- GeoPandas merge returns NaN or no matches โ the general form of this failure
- How to validate a GeoDataFrame against a schema before analysis โ catching a damaged id column at the door
- Attribute join or spatial join? โ why an id join is the right tool for census data
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.