Handling DBF Encoding and Field Name Truncation
A shapefile’s attribute table is a dBASE III file from 1983, and it loses two things on the way in: field names longer than ten characters, and any text whose code page it failed to record. Both losses are silent, both are irreversible from the file alone, and both surface months later as a column nobody can identify or a street name rendered as Ru� de la Paix. This page belongs to shapefile limitations in modern data stacks and covers the recovery procedure specifically.
Quick Reference
| Symptom | Cause | Fix | Primary Use Case |
|---|---|---|---|
PARCEL_REF where a long name was expected |
10-byte name field in the DBF descriptor | External name mapping, applied at conversion | Any legacy migration |
BUILDING_H and BUILDING_1 |
Two names truncated to the same ten characters | Detect collision, resolve from source schema | Layers with verbose schemas |
Ru� or Rué in text values |
Wrong code page assumed at read time | .cpg sidecar, then header byte, then detection |
Non-English attribute data |
| Names uppercased | Writer normalisation, not the format | Restore case from the mapping | Cosmetic but breaks joins |
Where the Two Losses Come From
The DBF field descriptor is a 32-byte fixed record. Eleven of those bytes hold the field name, ten usable plus a null terminator. There is no extension mechanism, no continuation record, and no place to store the original name — so parcel_reference_number becomes PARCEL_REF and the rest is gone. When two names collide after truncation, writers append a digit, which means the mapping from name to meaning now depends on the column order at export time.
The encoding problem has a different shape. Text in a DBF is bytes, and the file has two places to say which code page those bytes are in: an optional .cpg sidecar, and byte 29 of the header, the “language driver” identifier. Both are optional, both are frequently absent or wrong, and a reader that guesses wrong produces plausible-looking text with corrupted accented characters — which then propagates into every downstream system.
Detecting the Encoding and Restoring Names
The procedure is: prefer what is declared, detect when nothing is, and refuse to guess where a wrong guess would be unrecoverable.
# Requires: pyogrio>=0.9, geopandas>=1.0 (Python 3.10+)
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
CANDIDATES = ("utf-8", "cp1252", "cp1251", "iso-8859-1", "iso-8859-2", "cp850", "cp437")
# Byte 29 of the DBF header; only the values seen in practice are listed.
LANGUAGE_DRIVER = {
0x01: "cp437", 0x02: "cp850", 0x03: "cp1252", 0x57: "cp1252",
0x58: "cp1252", 0x59: "cp1252", 0x64: "cp852", 0x65: "cp866",
0xC8: "cp1250", 0xC9: "cp1251",
}
@dataclass(frozen=True)
class EncodingVerdict:
encoding: str
source: str # "cpg" | "header" | "detected"
confidence: float
def declared_encoding(dbf: Path) -> EncodingVerdict | None:
"""Return the encoding the dataset declares, if it declares one."""
cpg = dbf.with_suffix(".cpg")
if cpg.exists():
label = cpg.read_text(encoding="ascii", errors="ignore").strip()
if label:
return EncodingVerdict(label.lower(), "cpg", 1.0)
with open(dbf, "rb") as handle:
header = handle.read(32)
if len(header) < 30:
raise ValueError(f"{dbf} is truncated — not a readable DBF")
driver = header[29]
if driver in LANGUAGE_DRIVER:
return EncodingVerdict(LANGUAGE_DRIVER[driver], "header", 0.8)
return None
def detect_encoding(dbf: Path, *, expected_alphabet: str) -> EncodingVerdict:
"""Score each candidate code page against the letters the language uses.
Deliberately conservative: a low top score means the caller should ask a
human rather than accept a plausible-looking decode.
"""
with open(dbf, "rb") as handle:
sample = handle.read(1 << 20)
best, best_score = None, 0.0
for candidate in CANDIDATES:
try:
text = sample.decode(candidate)
except UnicodeDecodeError:
continue
letters = [c for c in text if c.isalpha()]
if not letters:
continue
score = sum(1 for c in letters if c in expected_alphabet) / len(letters)
if score > best_score:
best, best_score = candidate, score
if best is None or best_score < 0.9:
raise RuntimeError(
f"cannot confidently determine the encoding of {dbf} "
f"(best {best} at {best_score:.2f}) — supply it explicitly"
)
return EncodingVerdict(best, "detected", best_score)
def restore_names(
dbf_names: list[str], mapping: dict[str, str]
) -> dict[str, str]:
"""Map truncated DBF names back to full names, refusing ambiguity."""
collisions = [n for n in dbf_names if n[-1].isdigit() and n[:-1] + "_" in mapping]
if collisions:
raise ValueError(
f"truncation collisions need explicit resolution: {collisions}"
)
missing = [n for n in dbf_names if n not in mapping]
if missing:
raise KeyError(f"no full name recorded for {missing}")
return {short: mapping[short] for short in dbf_names}
Validation
Two checks catch the two failures. The first proves the encoding by looking for replacement characters and for byte sequences that decode to implausible letters; the second proves that names round-tripped.
# Requires: geopandas>=1.0 — assert the conversion preserved text and names
import geopandas as gpd
frame = gpd.read_parquet("parcels.parquet")
# 1. No replacement characters anywhere in the text columns.
text_cols = [c for c in frame.columns if frame[c].dtype == object and c != "geometry"]
bad = {c: int(frame[c].astype(str).str.contains("�").sum()) for c in text_cols}
assert not any(bad.values()), f"replacement characters survived conversion: {bad}"
# 2. Every column carries its restored full name, not the ten-character stub.
stubs = [c for c in frame.columns if len(c) == 10 and c.isupper()]
assert not stubs, f"columns still carrying truncated DBF names: {stubs}"
Expected results: zero replacement characters, zero remaining ten-character uppercase names, and a column count that matches the source schema exactly. A count mismatch means a collision was resolved by dropping a column rather than renaming it.
Edge Cases and Caveats
A .cpg that lies. Sidecars are written by hand as often as by software, and a file saying UTF-8 next to a cp1252 DBF is common. Treat the sidecar as authoritative but still run the scoring check as a validation, and log a warning when the two disagree rather than silently preferring one.
Names that differ only in case. Some writers uppercase all field names, so Area and area collide before truncation is even involved. Because target formats are case-sensitive, restoring the wrong case breaks joins downstream. Carry the case in the external mapping alongside the full name.
Mixed encodings inside one dataset. A layer assembled from several sources can genuinely contain both cp1252 and UTF-8 rows, and no single decode is correct. The honest handling is to decode per-row with a fallback chain, count how many rows needed the fallback, and surface that count — the same posture the pipeline takes toward null handling in schema mapping.
Frequently Asked Questions
Why are shapefile field names limited to ten characters?
The DBF field descriptor is a fixed 32-byte record whose name field is eleven bytes, ten of which hold characters and one of which is a null terminator. That layout comes from dBASE III in the early 1980s and cannot be extended without breaking every reader. So a column called parcel_reference_number becomes parcel_ref on the way in, and there is nowhere in the file to record what it used to be.
How do I find out what encoding a DBF file uses?
Check three places in order. A .cpg sidecar file next to the .dbf names the code page explicitly and is authoritative when present. Byte 29 of the DBF header holds a language driver identifier that maps to a code page, though many writers leave it zero. Failing both, decode a sample of the text columns under each plausible code page and score the results — real words in the expected language score high, and mojibake scores low.
What happens when two long field names truncate to the same ten characters?
Most writers append a digit, so building_height and building_heat_source become BUILDING_H and BUILDING_1. That silently destroys the association between name and meaning: nothing in the file says which is which, and the numbering depends on column order, so two exports of the same layer can assign them differently. Detect the collision explicitly against the source schema and resolve it from an external mapping rather than trusting the suffix.
Related
- Shapefile Limitations in Modern Data Stacks — parent guide: the full list of what the format cannot represent
- Why Shapefiles Fail at Scale — the size and concurrency limits that sit alongside these encoding ones
- Mapping DBF Field Types to Arrow Schema — the type half of the same conversion problem
- Schema Mapping for Legacy to Modern Formats — where the external name mapping lives in the pipeline