Bridge Rating Averages Come Out Too Low: N Codes Read as Zero

On this page

Problem statement

Average the deck ratings in the National Bridge Inventory and you may get 4.94 β€” a national bridge stock in poor condition β€” when the right answer is 6.56. Rank states the same way and Arizona, New Mexico and Tennessee come out worst, when they are among the better ones. The cause is the letter N in the rating columns: it marks a component the structure does not have, mostly the deck of a culvert, and code that forces the column to numbers with replace("N", "0") or fillna(0) turns every culvert into a failed deck. This page measures the damage on the 2025 inventory and shows how to compute averages, minimums and rankings that respect N.

Quick answer

Convert rating columns with pd.to_numeric(errors="coerce"), so N becomes missing and drops out of means and minimums. Never replace("N", "0"). For a single rating per bridge use the lowest of the four components, as FHWA does.

comp = ["DECK_COND_058", "SUPERSTRUCTURE_COND_059", "SUBSTRUCTURE_COND_060", "CULVERT_COND_062"]
print("share of N in each item:", (nbi[comp] == "N").mean().round(3).to_dict())
as_num = nbi.DECK_COND_058.pipe(pd.to_numeric, errors="coerce")
print(f"mean deck rating ignoring N: {as_num.mean():.2f}; with N read as 0 by mistake: {as_num.fillna(0).mean():.2f}")
share of N in each item: {'DECK_COND_058': 0.247, 'SUPERSTRUCTURE_COND_059': 0.238, 'SUBSTRUCTURE_COND_060': 0.238, 'CULVERT_COND_062': 0.762}
mean deck rating ignoring N: 6.56; with N read as 0 by mistake: 4.94

24.7% of deck ratings and 76.2% of culvert ratings are N. Ignoring N, the mean deck rating is 6.56; reading N as 0 drags it to 4.94 β€” from fair-to-good to poor, without a single bridge changing.

Paired bars of mean deck rating for eight states with N treated as missing and N replaced by zero, the second method ranking culvert-heavy states lowest Paired bars of mean deck rating for eight states with N treated as missing and N replaced by zero, the second method ranking culvert-heavy states lowest
Treating N as 0 makes Arizona (2.86) look worst; done properly, Puerto Rico (5.66) is lowest.

What N stands for

1. Bridges have a deck; culverts do not

culvert = nbi.CULVERT_COND_062 != "N"
print(pd.crosstab(culvert, nbi.DECK_COND_058 == "N", rownames=["culvert"], colnames=["deck N"]).to_string())
deck N    False   True 
culvert                
False    469910    5728
True         74  148481

The split is nearly perfect: 148,481 culverts have deck N, and only 74 culverts have a deck rating. On the other side, 5,728 bridges that are not culverts have deck N.

2. Some N decks are bridges

odd = (~culvert) & (nbi.DECK_COND_058 == "N")
print(f"non-culverts with deck N: {odd.sum():,}; their superstructure N: {(nbi.loc[odd, 'SUPERSTRUCTURE_COND_059'] == 'N').mean():.1%}")
DTYPE = {"N": "not applicable / no deck", "1": "concrete cast-in-place", "2": "concrete precast panels", "8": "wood or timber", "9": "other"}
print("their deck structure type (item 107):", nbi.loc[odd, "DECK_STRUCTURE_TYPE_107"].str.strip().map(DTYPE).fillna("other").value_counts().head(4).to_dict())
non-culverts with deck N: 5,728; their superstructure N: 0.0%
their deck structure type (item 107): {'not applicable / no deck': 5138, 'concrete cast-in-place': 341, 'other': 173, 'concrete precast panels': 60}

Those 5,728 are real bridges without a separate deck β€” mostly structures whose deck structure type is itself N, such as slab bridges and others where the superstructure forms the riding surface β€” and none of them has superstructure N. For them, N is genuinely not applicable, and the superstructure rating carries the condition.

3. Use the lowest rating, not one item

low = nbi[comp].apply(pd.to_numeric, errors="coerce").min(axis=1)
print(f"bridges with a usable lowest rating: {low.notna().mean():.2%}; matches FHWA's LOWEST_RATING: {(low == pd.to_numeric(nbi.LOWEST_RATING)).mean():.2%}")
bridges with a usable lowest rating: 100.00%; matches FHWA's LOWEST_RATING: 100.00%

Taking the minimum over all four components with N as missing gives every bridge a usable rating (100.00%), and it equals FHWA's LOWEST_RATING for every bridge. Whatever the mix of N, each structure has at least one rated component.

Bar chart of the share of structures rated N for deck, superstructure, substructure and culvert Bar chart of the share of structures rated N for deck, superstructure, substructure and culvert
24.7% of deck ratings and 76.2% of culvert ratings are N.

4. Averages that respect N

by = nbi[comp].apply(pd.to_numeric, errors="coerce").groupby(nbi.state).mean()
print("mean deck rating, lowest states:", by.DECK_COND_058.nsmallest(3).round(2).to_dict())
wrong = nbi.DECK_COND_058.replace("N", "0").astype(int).groupby(nbi.state).mean()
print("the same with N as 0, lowest:", wrong.nsmallest(3).round(2).to_dict())
mean deck rating, lowest states: {'PR': 5.66, 'ME': 6.02, 'WV': 6.08}
the same with N as 0, lowest: {'AZ': 2.86, 'NM': 3.42, 'TN': 3.56}

Done properly, the lowest mean deck ratings are Puerto Rico (5.66), Maine and West Virginia. With N as 0, the "worst" become Arizona (2.86), New Mexico and Tennessee β€” the states with the most culverts, which have no deck. The wrong method ranks states by their share of culverts.

N elsewhere, and culverts by state

The data used on this page

The Federal Highway Administration's National Bridge Inventory for 2025, condition items 58 to 62 and the derived LOWEST_RATING and BRIDGE_CONDITION columns.

import zipfile
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
# FHWA National Bridge Inventory, 2025 submission, highway bridges, all states in one comma-delimited file (53 MB zip).
# The server is slow and drops long downloads, so resume until the file is complete.
URL = "https://www.fhwa.dot.gov/bridge/nbi/2025hwybronefiledel.zip"
f = Path("data/2025hwybronefiledel.zip")
size = int(requests.head(URL, allow_redirects=True, timeout=60).headers["Content-Length"])
while not f.exists() or f.stat().st_size < size:
    have = f.stat().st_size if f.exists() else 0
    try:
        with requests.get(URL, headers={"Range": f"bytes={have}-"}, stream=True, timeout=120) as r, open(f, "ab") as out:
            for chunk in r.iter_content(1 << 20):
                out.write(chunk)
    except requests.RequestException:
        pass                                                   # resume from what arrived
with zipfile.ZipFile(f) as z:
    nbi = pd.read_csv(z.open("2025HwyBridgesDelimitedAllStates.txt"), quotechar="'", dtype=str, low_memory=False)


def dms(s, west=False):
    """NBI packs coordinates as DDMMSSss (latitude) and DDDMMSSss (longitude, positive west)."""
    s = s.str.strip().str.zfill(9 if west else 8)
    d = s.str[:-6].astype(float) + s.str[-6:-4].astype(float) / 60 + s.str[-4:].astype(float) / 100 / 3600
    return -d if west else d


nbi["lat"], nbi["lon"] = dms(nbi.LAT_016), dms(nbi.LONG_017, west=True)
nbi["cond"] = nbi.BRIDGE_CONDITION                                   # G, F or P, derived by FHWA
nbi["adt"] = pd.to_numeric(nbi.ADT_029, errors="coerce")
nbi["built"] = pd.to_numeric(nbi.YEAR_BUILT_027, errors="coerce")
nbi["area"] = pd.to_numeric(nbi.DECK_AREA, errors="coerce")
for name in ["cb_2023_us_state_20m.zip"]:
    if not Path(f"data/{name}").exists():
        Path(f"data/{name}").write_bytes(requests.get(f"https://www2.census.gov/geo/tiger/GENZ2023/shp/{name}", timeout=300).content)
states = gpd.read_file("data/cb_2023_us_state_20m.zip").to_crs(4326)
nbi["state"] = nbi.STATE_CODE_001.map(states.set_index("STATEFP").STUSPS)
print(f"{len(nbi):,} highway bridges, {nbi.shape[1]} fields, {nbi.STATE_CODE_001.nunique()} state codes")
624,193 highway bridges, 130 fields, 53 state codes

Example 1: N in the other coded fields

n_any = {c: round(float((nbi[c].str.strip() == "N").mean()), 3) for c in nbi.columns if pd.api.types.is_string_dtype(nbi[c]) and (nbi[c].str.strip() == "N").any()}
print(f"{len(n_any)} fields use N; the most: {dict(sorted(n_any.items(), key=lambda kv: -kv[1])[:5])}")
27 fields use N; the most: {'FED_AGENCY': 0.982, 'PARALLEL_STRUCTURE_101': 0.877, 'UNDCLRENCE_EVAL_069': 0.826, 'SPEC_INSPECT_092C': 0.825, 'VERT_CLR_UND_REF_054A': 0.824}

N is used in 27 fields, from federal agency (98.2% N, because most bridges are not on federal land) to evaluations of under-clearance (82.6%). The same rule applies to all of them.

Map of the lower 48 states shaded by the share of structures that are culverts, highest in Arizona, New Mexico, Minnesota and Tennessee Map of the lower 48 states shaded by the share of structures that are culverts, highest in Arizona, New Mexico, Minnesota and Tennessee
Culverts are 55.9% of Arizona's structures, so a deck average with N as 0 punishes it most.

Example 2: share of culverts by state

print("culvert share, highest:", culvert.groupby(nbi.state).mean().nlargest(5).round(3).to_dict())
culvert share, highest: {'AZ': 0.559, 'NM': 0.457, 'MN': 0.456, 'TN': 0.45, 'NV': 0.427}

Culverts are 55.9% of Arizona's structures and 45.7% of New Mexico's β€” dry washes crossed by box culverts β€” which is why a deck average with N as 0 punishes those states most.

N is a category, not a number

The NBI coding guide uses N wherever an item does not apply to a structure: a culvert has no deck, a bridge over land has no channel, a state bridge has no federal agency. In a numeric column it plays the role NULL plays in a database. Pandas reads the column as text because of it, which is a useful warning; the mistake comes when someone forces the column to numbers by replacing the letter.

FHWA's own derived columns (LOWEST_RATING, BRIDGE_CONDITION) already handle N correctly, so for most analysis they are the safest starting point; the component columns are for questions about decks or substructures specifically.

Related placeholders

  • Other letters. Scour uses U (unknown) and T (tidal) besides N; each needs its own handling.
  • Blank values occur in some items; errors="coerce" turns them into missing too.
  • Culvert-only analyses should filter on the culvert rating not being N, not on the deck being N.
  • Group means of ratings should report how many structures had a rating, as the denominator varies by component.

FAQ

What does N mean in a bridge condition rating?

Not applicable: the structure does not have that component. Culverts have N for deck, superstructure and substructure; bridges have N for culvert.

Should I replace N with 0?

No. In the 2025 inventory that turns a mean deck rating of 6.56 into 4.94. Treat N as missing.