How to Aggregate Survey Estimates and Their Margins of Error
Problem statement
Census tracts rarely match the area you care about. A neighbourhood, a service area or a school catchment is usually a set of tracts, and the estimate for the set is easy: add the tract estimates. The margin of error is not. Margins do not add, and there is no exact way to combine them from published tables.
The Census Bureau's standard approximation is the root sum of squares. Measured by aggregating 2023 ACS tracts to counties and comparing with the margins the Bureau publishes for those counties:
- For people below the poverty line it works reasonably well: the approximation was a median 4.7% wider than the published margin, and within 10% for 67.5% of counties.
- For zero estimates it overstates badly unless you apply the zero rule: 36.4% too wide at the median, down to 5.1% with it.
- For controlled totals it is nonsense: the aggregated margin of the poverty universe was a median 12.6 times the published one.
- Medians cannot be aggregated at all: a household-weighted average of tract median incomes missed the published county median by a median 2.3%, and by more than 7.2% in a tenth of counties.
Quick answer
Sum the estimates, combine the margins by root sum of squares, and among zero estimates keep only the largest margin:
import numpy as np
def combine_moe(estimates, moes):
nonzero = moes[estimates > 0]
zeros = moes[estimates == 0]
extra = zeros.max() ** 2 if len(zeros) else 0.0
return np.sqrt((nonzero ** 2).sum() + extra)
total = tracts["B17001_E002"].sum()
moe = combine_moe(tracts["B17001_E002"], tracts["B17001_M002"])
For a rate, aggregate the numerator and denominator first, then compute the rate and its margin from the aggregates. Never average tract rates, and never average tract medians.
Step-by-step solution
1. Sum the estimates
Counts add exactly. Summing tract estimates reproduced the published county estimate for 99.9% of counties, and the exceptions were the two New York counties with tracts missing from the table. If a sum does not match a published parent, rows are missing.
2. Combine margins by root sum of squares
For independent estimates, variances add, and a 90% margin is a scaled standard error, so:
MOE(A + B + C) โ sqrt(MOE_Aยฒ + MOE_Bยฒ + MOE_Cยฒ)
How good the approximation is depends on the level and the variable:
aggregation RSS รท published MOE within 10%
block groups โ tract, renter households 1.049 66.1%
tracts โ county, people below poverty 1.047 67.5%
counties โ state, people below poverty 0.930 55.8%
tracts โ county, poverty universe 12.592 7.1%
For most counties it is within about 10% and errs on the wide side. At state level it errs narrow: 38.5% of states came out more than 10% too narrow.
3. Apply the zero rule
A zero estimate still has a margin โ often the same small number in every tract. Summing all of them in quadrature counts the same "found nobody" uncertainty dozens of times. The Census Bureau's guidance is to include only the largest margin among the zero estimates.
The effect is large for sparse cells. B17001_E019, one of the age groups below poverty, is zero in 84.5% of tracts, and most of those zeros carry a margin of 13, 14, 15 or 19:
B17001_E019, tracts โ county RSS รท published within 10%
all margins included 1.364 18.8%
zero rule 1.051 50.0%
Among the 2,137 counties whose estimate for that cell is under 50, the median ratio fell from 1.477 to 1.086.
4. Do not aggregate the margins of controlled totals
The poverty universe, total population and housing totals are controlled to official estimates at county level. Their tract margins describe sampling variation that cancels out once the tracts are combined into the controlled area, so root sum of squares inflates them โ to 12.6 times the published margin for the poverty universe.
For a custom region inside one county, report the controlled total with its aggregated margin marked as an overstatement, or use the published parent where the region matches one.
5. Build rates from aggregated numerators and denominators
Aggregate the count below poverty and the poverty universe separately, then compute the proportion and its margin from the aggregates. The inflated universe margin from step 4 matters less than you might expect, because the proportion formula subtracts its term: the county poverty-rate margins derived from aggregated tracts matched the ones derived from published county counts with a median ratio of 1.013.
Averaging tract rates instead weights a tract of 1,200 people the same as one of 8,000, and gives no margin at all.
6. Rebuild medians from distributions, not from medians
A median of medians is not a median. A household-weighted mean of tract median household incomes missed the published county median by a median of 2.3%, by more than 7.2% for a tenth of counties, and was biased upwards by 2.4% on average.
For a custom region, aggregate the bracket counts from the distribution table โ household income brackets are in B19001 โ and interpolate the median within the bracket that contains the middle household.
7. Check the method against a published aggregate
Where your region matches a published geography, compare. New York City is five counties and is also published as a place:
people below poverty MOE poverty rate rate MOE
five counties, RSS 1,454,320 17,545 17.36% 0.21 pts
published NYC place 1,454,320 17,927 17.36% 0.21 pts
The estimates are identical, the aggregated margin is 2.1% narrower than the published one, and the rate and its margin match to two decimal places.
Code examples
Example 1 โ aggregate estimates and margins by group
import numpy as np
import pandas as pd
def aggregate_estimates(frame, groups, pairs, zero_rule=True):
"""Sum estimates by group and combine MOEs by root-sum-of-squares.
pairs: [(estimate_column, moe_column), ...]
zero_rule: among zero estimates in a group, keep only the largest MOE (Census Bureau guidance).
"""
out = {}
for est, moe in pairs:
g = frame[[est, moe]].groupby(groups)
out[est] = g[est].sum()
def combine(part):
nonzero = part.loc[part[est] > 0, moe]
zeros = part.loc[part[est] == 0, moe]
if not zero_rule:
return np.sqrt((part[moe] ** 2).sum())
extra = zeros.max() ** 2 if len(zeros) else 0.0
return np.sqrt((nonzero ** 2).sum() + extra)
out[moe] = g.apply(combine)
return pd.DataFrame(out)
groups can be anything pandas can group by: a column of region names, or a slice of the GEOID such as tracts.index.str[:5] for counties. Replace sentinel values with missing values before calling it, or a โ555555555 margin will dominate the sum.
Example 2 โ a rate and its margin for the aggregate
def aggregate_rate(agg, num, moe_num, den, moe_den):
"""Rate and its MOE for an aggregated numerator that is a subset of the denominator."""
p = agg[num] / agg[den]
radicand = agg[moe_num] ** 2 - p ** 2 * agg[moe_den] ** 2
ratio = agg[moe_num] ** 2 + p ** 2 * agg[moe_den] ** 2
moe = np.sqrt(np.where(radicand < 0, ratio, radicand)) / agg[den]
return pd.DataFrame({"rate": p, "rate_moe": moe}, index=agg.index)
Applied to New York City's five counties:
nyc_counties = ["36005", "36047", "36061", "36081", "36085"]
sub = counties.loc[nyc_counties]
nyc = aggregate_estimates(sub, pd.Series("NYC", index=sub.index),
[("B17001_E001", "B17001_M001"), ("B17001_E002", "B17001_M002")])
print(nyc.join(aggregate_rate(nyc, "B17001_E002", "B17001_M002", "B17001_E001", "B17001_M001")))
{'B17001_E001': 8378514, 'B17001_M001': 2699.9776, 'B17001_E002': 1454320,
'B17001_M002': 17544.6869, 'rate': 0.1736, 'rate_moe': 0.0021}
The published place row for New York City has margins of 2,546 and 17,927 for the same two estimates, and the same rate and rate margin.
Example 3 โ test the approximation on your own variables
def against_published(agg, published, est, moe):
ratio = (agg[moe] / published[moe]).replace([np.inf, -np.inf], np.nan).dropna()
ratio = ratio[published.loc[ratio.index, moe] > 0]
print(f"{moe}: RSS / published median {ratio.median():.3f}, within 10% {ratio.between(0.9, 1.1).mean():.1%}, "
f"more than 10% too wide {(ratio > 1.1).mean():.1%}, too narrow {(ratio < 0.9).mean():.1%}")
return ratio
B17001_M002: RSS / published median 1.047, within 10% 67.5%, more than 10% too wide 30.8%, too narrow 1.7%
B17001_M001: RSS / published median 12.592, within 10% 7.1%, more than 10% too wide 92.8%, too narrow 0.1%
zero_rule False B17001_M019: RSS / published median 1.364, within 10% 18.8%, more than 10% too wide 81.2%, too narrow 0.1%
zero_rule True B17001_M019: RSS / published median 1.051, within 10% 50.0%, more than 10% too wide 36.1%, too narrow 13.9%
Run it with tracts aggregated to counties for any variable before trusting aggregated margins for a custom region. A variable that fails at county level โ like the controlled universe on the second line โ will fail for your region too.
Explanation
Why root sum of squares is only an approximation
Adding variances is exact for independent estimates. ACS estimates for neighbouring areas are not independent: they share weighting steps, and county-level controls tie tract estimates together. When tracts are forced to add up to a controlled county total, an overestimate in one tract tends to come with underestimates in others. That negative correlation makes the true margin of the sum smaller than the root sum of squares, which is why the approximation errs wide for counties.
At state level the published margins were more often larger than the approximation, consistent with county estimates within a state being positively correlated. The formula has no way to know either.
Why controlled totals break it completely
A controlled total has almost no sampling error at the controlled level, so its published county margin is tiny or absent. The tract margins still reflect sampling within the county, and they cancel exactly when summed back to the county โ but root sum of squares assumes they never cancel. The result for the poverty universe was a margin 12.6 times too large.
Why zeros need a special rule
A margin on a zero estimate is not derived from a sample of people; there were none in the sample to measure. The Bureau publishes a floor that depends on the survey design, which is why the same values โ 13, 14, 15, 19 โ repeat across thousands of tracts. Treating each as independent evidence multiplies one piece of uncertainty by the number of empty tracts. Keeping only the largest is a pragmatic correction, and it brought the median ratio for a sparse cell from 1.364 to 1.051.
Why medians do not aggregate
A median depends on the full distribution of households, and two tracts with the same median can have very different spreads. Averaging medians โ even weighted by households โ ignores the spread, so the result drifts from the true combined median, and in these counties it drifted upwards on average. Only the underlying distribution, aggregated bracket by bracket, contains the information needed.
Edge cases or notes
- Replace sentinels before aggregating. A single โ555555555 margin squared overwhelms any sum.
- The approximation errs wide for tracts within a county and narrow more often at state level; say which in any report.
- Large aggregations drift more. The median ratio for counties with more than 20 tracts was about 1.06โ1.08, against 1.016 for counties with five or fewer.
- Derived rates tolerate the controlled-total problem better than totals do, because the proportion formula subtracts the denominator term.
- Do not aggregate across years. Five-year periods overlap, and their margins cannot be combined this way.
- Medians, means and ratios published directly cannot be summed. Aggregate their components instead.
- Check a published parent whenever one exists. A county, place or metropolitan area that matches your region is a free test of the method.
- Aggregating improves reliability. Combining tracts until the CV falls below your threshold is often the point of the exercise.
Internal links
- Margins of error explained: why survey estimates need their uncertainty โ what the margins mean before combining them
- Small numbers explained: why rates in small areas jump around โ aggregation as a cure for unstable rates
- How to smooth unstable area rates with Empirical Bayes in Python โ the alternative to merging areas
- Census geographies explained: blocks, tracts, output areas and why they nest โ which areas add up to which
- Fixing census totals that do not add up after aggregation โ when the sums themselves disagree
- Fixing census API values like โ666666666 and other sentinels โ cleaning margins before aggregating
- How to aggregate spatial data by region in GeoPandas โ assigning tracts to custom regions
- The modifiable areal unit problem explained โ why the grouping changes the answer
FAQ
How do I combine margins of error for several census tracts?
Take the square root of the sum of the squared margins, including only the largest margin among tracts whose estimate is zero. For tract poverty counts aggregated to counties, that was within 10% of the published margin for 67.5% of counties.
Can I add margins of error together?
No. Adding them directly overstates the uncertainty far more than root sum of squares does. The squares add, approximately, not the margins.
How do I calculate a poverty rate for a group of tracts?
Sum the people below poverty and the poverty universe across the tracts, then divide. Compute the rate's margin from the two aggregated margins with the proportion formula; averaging tract rates gives the wrong answer and no margin.
Why is my aggregated margin of error so large for population totals?
Total population and similar totals are controlled to official estimates at county level, so their tract margins cancel when summed. Root sum of squares cannot see that and gave margins 12.6 times the published value for the poverty universe.
Can I average median household income across tracts?
No. A household-weighted mean of tract medians missed the published county median by a median of 2.3%, and by more than 7.2% for one county in ten. Aggregate the income bracket counts and interpolate a new median.
How accurate is the root-sum-of-squares approximation?
It depends on the variable and the level. Aggregating New York City's five counties gave a margin 2.1% narrower than the published city margin; for tracts to counties it ran a median 4.7% wide.