How is global atmospheric CO₂ changing, and where are the strongest sources?
Draw a rectangle, then check which collections cover it. Most NASA data is global, so a big box matches a lot.
-95.5, 29.5 → -95, 30 (Houston metro area)CO₂ (carbon dioxide) is the main heat-trapping gas we add to the air by burning coal, oil, and gas. Satellites can't sniff the air directly from orbit. Instead they measure XCO₂, the average amount of CO₂ in the whole column of air from the ground up to space, written in ppm (parts per million, just a concentration). Watch that number over years and you see the planet's CO₂ climbing. Look at where it's highest and you can spot the big emitters below.
How is global atmospheric CO₂ changing, and where are the strongest sources?
CO₂ (carbon dioxide) is the main heat-trapping gas we add to the air by burning coal, oil, and gas. Satellites can’t sniff the air directly from orbit. Instead they measure XCO₂, the average amount of CO₂ in the whole column of air from the ground up to space, written in ppm (parts per million, just a concentration). Watch that number over years and you see the planet’s CO₂ climbing. Look at where it’s highest and you can spot the big emitters below.
Which data to use, and how
Use this dataset: OCO-2 XCO₂ (short name OCO2_L2_Lite_FP),
which covers 2014 → today and is the easiest place to start, because the global trend is the simplest
thing to compute. There’s also a newer twin, OCO-3 XCO₂ (OCO3_L2_Lite_FP),
which can zoom in on a single city or power plant. Save that for after you’ve got the global picture working.
Then do four steps (the runnable code further down does all of this; this is just the idea):
- Pick global and a time window. For the trend you want the whole planet, so no bounding box, and a run of years (e.g. 2015 → 2025).
- Keep only the good measurements. Each reading carries a quality flag. Keep only the ones marked
“good” (
xco2_quality_flag == 0) and throw the rest away. - One number per month. Average the good XCO₂ readings into a monthly value (optionally onto a coarse grid, like 2°×2° cells), giving a monthly time series.
- Draw the trend and the seasons. Fit the long-term rise (about 2.5 ppm per year right now) and the yearly up-and-down wiggle (plants pull CO₂ down in summer, release it in winter).
That’s the whole method. Below, “How a scientist answers this” names the exact tools, and the Run it cell does it live on synthetic data so you can see each step work.
What you can find out
- The global CO₂ trend. The steady rise (~2.5 ppm/yr now), the yearly seasonal wiggle, and the annual growth rate.
- Where it’s higher or lower. Highest over Asia’s industrial corridors, lowest over the Southern Ocean.
- Individual big emitters. Megacities and power plants, using OCO-3’s zoom mode (called SAM) when the sky is clear.
- How plants breathe. Using SIF (Solar-Induced Fluorescence, a faint glow leaves give off while photosynthesizing), you can compare forests vs croplands vs grasslands.
- El Niño / La Niña effects. How these ocean climate cycles shift how much carbon tropical land takes up (a major OCO finding).
What it can’t tell you
- The actual emission rate (kilograms of CO₂ per second). The satellite sees a concentration, not a flow. Turning the gas you see into the gas being emitted needs an atmospheric-transport model that runs the air backwards (an “inversion”; use the OCO MIP flux estimates).
- One specific factory’s emissions. To attribute CO₂ to a single facility you also need accurate wind and how deep the near-ground mixing layer is. Pull those from MERRA-2 (its PBL height and 850 hPa winds).
- Methane. OCO instruments only see CO₂. Methane lives on other sensors (TROPOMI, EMIT, GOSAT, MERLIN), not these.
Gotchas to watch for
- Most of the data gets thrown away. Clouds and haze block the view, so cloud-screening removes 75–90% of footprints. Sparse, patchy coverage is normal. The fix is to average up to monthly (or coarser) so the gaps fill in.
- Trust only the “good” flag. Every reading has a quality flag: keep
xco2_quality_flag == 0(good), treat== 1as questionable, and discard== 2. Skip this and bad retrievals will poison your trend. - Watch the version number when comparing years. NASA re-processes the data and changes its bias corrections (e.g. v11.2 vs v10). If your time span crosses a version change, a jump might be the processing, not the air. Use one consistent version for a trend.
- SIF is only a stand-in for photosynthesis (not gross primary production measured directly). The link between the glow and actual plant growth shifts with the ecosystem, water stress, and species mix, so read it as a proxy, not a precise number.
- OCO-3’s zoom mode is scheduled, not guaranteed. SAM targets are chosen by a planner, so your city or plant won’t be observed on every overpass. Expect gaps in facility-scale data.
The real-data code
The Run it cell above runs the method on synthetic data (no login). Below is the whole recipe against the real archive: search OCO-2, open each Lite granule, keep only good-quality XCO₂, average per month, fit the long-term rise plus the seasonal wiggle, and plot it. It needs a free Earthdata Login.
import earthaccess, numpy as np, xarray as xr
import matplotlib.pyplot as plt
earthaccess.login(strategy="netrc")
aoi = None # global, for the planet-wide trend
window = ("2015-01-01", "2024-12-31")
# 1. Find the OCO-2 Lite files (one netCDF per day) over the whole window.
grans = earthaccess.search_data(short_name="OCO2_L2_Lite_FP", temporal=window)
print(f"{len(grans)} daily granules")
# 2. Open each granule, keep only good retrievals (xco2_quality_flag == 0),
# and reduce every day to (year-month, mean ppm). OCO-2 Lite is a 1-D list of
# sounding footprints, so this is just masked averaging — no gridding needed.
from collections import defaultdict
buckets = defaultdict(list)
for fh in earthaccess.open(grans):
ds = xr.open_dataset(fh)
good = ds["xco2_quality_flag"].values == 0
xco2 = ds["xco2"].values[good] # ppm
t = ds["time"].values[good].astype("datetime64[M]") # truncate to month
if xco2.size == 0:
continue
for ym in np.unique(t):
buckets[ym].append(np.nanmean(xco2[t == ym]))
months = np.array(sorted(buckets))
xco2_m = np.array([np.nanmean(buckets[m]) for m in months])
# decimal year as the regression axis
yr = months.astype("datetime64[Y]").astype(int) + 1970
mo = months.astype("datetime64[M]").astype(int) % 12
tdec = yr + mo / 12.0
# 3. Fit trend + seasonal harmonic in one least-squares solve:
# xco2 = a + b*t + c*sin(2πt) + d*cos(2πt). b is the ppm/yr growth rate.
ph = 2 * np.pi * tdec
A = np.column_stack([np.ones_like(tdec), tdec, np.sin(ph), np.cos(ph)])
coef, *_ = np.linalg.lstsq(A, xco2_m, rcond=None)
growth = coef[1]
fit = A @ coef
# 4. Verdict + plot (monthly means, the straight rise, and the full seasonal fit).
print(f"Global XCO2 is rising at {growth:+.2f} ppm/yr "
f"({xco2_m[0]:.1f} -> {xco2_m[-1]:.1f} ppm over the record)")
plt.plot(tdec, xco2_m, ".", label="monthly mean XCO2")
plt.plot(tdec, coef[0] + coef[1] * tdec, "--", c="k", label=f"trend {growth:+.2f} ppm/yr")
plt.plot(tdec, fit, c="C1", lw=1, label="trend + seasonal fit")
plt.ylabel("XCO2 (ppm)"); plt.xlabel("year"); plt.legend(); plt.tight_layout(); plt.show()
# Sanity check: ~2.5 ppm/yr should match NOAA's Mauna Loa in-situ growth rate
# (gml.noaa.gov/ccgg/trends). If it doesn't, you let in bad flags or crossed a version change.
Where the data comes from
All of it comes from NASA through one free login (Earthdata Login). OCO-2, OCO-3, and MERRA-2 all live
in the same GES DISC archive and are fetched the same way with earthaccess.
Sources
Make it yours → Choose global or a regional/city box, the year range, and the quality filter in the notebook; switch OCO-2 nadir for OCO-3 SAM mode to target a specific facility.
A safe place to practise the method on OCO3_L2_Lite_FP. The code is the real earthaccess workflow, but we feed it stand-in data shaped like the real product — so you can run it, change the numbers, and break it without an account. When you want the real data, use the recipe under “The real-data code” above, or download the notebook below.