TL;DR — The electricity grid is not equally dirty all the time. Its carbon intensity
CI(t)— grams of CO₂ per kWh — rises and falls every hour because the fuel mix changes: windy nights and sunny middays lean on wind/solar/nuclear (clean), while evening peaks fire up gas, coal, and oil (dirty). The same kWh can emit ~6× more CO₂ depending only on WHEN it runs. That single fact — that timing, not just amount, drives emissions — is the whole reason carbon-aware scheduling works.
1. Simple explanation
Most people think electricity is just electricity. A kWh is a kWh. But how that kWh was made changes minute to minute, because the grid mixes many power sources at once — wind, solar, nuclear, hydro, gas, coal, oil — and the recipe keeps changing.
When lots of wind is blowing at 3 a.m., the grid is running mostly on wind and nuclear. Very little CO₂ per kWh. When everyone comes home at 6 p.m. and switches on ovens and AC, demand spikes, and the grid turns on fast, dirty "peaker" gas and oil plants to keep up. Now each kWh carries a lot more CO₂.
So the grid has clean hours and dirty hours, and they repeat in patterns you can predict.
Analogy — the highway. Think of carbon intensity like traffic on a highway. The road (the grid) is the same road all day. But when you drive matters enormously. Drive at 3 a.m. and it's empty — fast and cheap. Drive at 6 p.m. rush hour and you crawl — same distance, far more time and fuel wasted. The road didn't change; the timing did. Grid carbon works the same way: same wire, same kWh, but drive your compute into the "3 a.m." of carbon and you emit a fraction of the CO₂.
The trick is that you often get to choose when to drive. A nightly report, a model-training job, a batch of video encodes — none of these care whether they run at 6 p.m. or 3 a.m. If you shift them into the clean hours, you cut their emissions for free. That is carbon-aware computing in one sentence.
2. Diagram
CARBON INTENSITY OVER A DAY (gCO2/kWh) — same grid, changes every hour
gCO2
/kWh
400 | #### <- EVENING PEAK
| ### ### gas+coal+oil
300 | ### ## (DIRTY ~350)
| . ## ##
200 | . . ## ##
| . . ## ###
100 | . . . . . .## ###......
| .. SOLAR DIP (midday sun, CLEAN) ....
60 |.. WIND NIGHT (CLEAN ~59) ..
+----+----+----+----+----+----+----+----+----+----+----+----+----+
00 02 04 06 08 10 12 14 16 18 20 22 24 hour
HOW IT'S BUILT each hour t:
gen_wind(t) gen_solar(t) gen_nuclear(t) gen_gas(t) gen_coal(t) ...
\ \ | / /
\ \ | / /
+----------+----------+----------+----------+
|
weight each MWh by its EMISSION FACTOR
|
v
CI(t) = generation-weighted average (gCO2/kWh)
3. How it works
3.1 What "carbon intensity" means
Carbon intensity CI(t) is the average CO₂ emitted per unit of electricity delivered in hour t. Units: grams CO₂ per kWh (gCO₂/kWh). Low = clean, high = dirty.
It is a generation-weighted average of the fuels running right now. Each fuel contributes in proportion to how much power it is producing, weighted by how dirty that fuel is.
3.2 Emission factors — how dirty each fuel is
Every fuel has an emission factor EF_f: the gCO₂ per kWh for that source. We use the IPCC AR5 lifecycle factors (they include building the plant and supplying the fuel, not just the smokestack — see the companion article):
| Fuel | EF (gCO₂/kWh) | Notes |
|---|---|---|
| Coal | 820 | Dirtiest common source |
| Oil | 650 | Peaker / backup |
| Gas | 490 | The usual "swing" fuel |
| Other | ≈230 | Biomass/mixed/unknown |
| Solar | 48 | Lifecycle (panel manufacturing) |
| Hydro | 24 | Low, some reservoir methane |
| Nuclear | 12 | Very low carbon |
| Wind | 11 | Cleanest at scale |
The spread is huge: coal is ~75× dirtier than wind per kWh. That is why the mix dominates CI(t).
3.3 Why it swings — diurnal (daily) patterns
| Time of day | Typical driver | Effect on CI |
|---|---|---|
| Overnight (00–05) | Low demand, wind often strong, nuclear steady | Clean (a common clean window) |
| Morning ramp (06–09) | Demand rises, gas comes on | Rising |
| Midday (11–15) | Solar peaks in sunny regions | Dip (clean in CA/TX) |
| Evening peak (17–21) | Demand peak, solar gone, peakers fire | Dirty (worst window) |
| Late night (22–24) | Demand falls, peakers off | Falling |
Two clean windows recur: overnight wind and, in solar-heavy grids, the midday solar dip. The dirtiest window is almost always the evening peak, when demand is high and solar has set, so gas/oil/coal cover the gap.
3.4 Seasonal patterns
- Winter: high heating demand, long dark evenings → more fossil peaking; but strong winter winds can produce very clean nights.
- Spring/Fall: mild demand + good wind/solar → often the cleanest whole days of the year.
- Summer: AC drives afternoon/evening peaks; solar helps midday but fades exactly when the evening peak hits ("the duck curve").
3.5 The key insight: timing beats amount
Two things determine a task's emissions:
emissions (gCO2) = energy used (kWh) × CI at run-time (gCO2/kWh)
└ how MUCH ┘ └ how DIRTY / WHEN ┘
Most engineers only optimize the left term (use less energy). But the right term swings ~6× on the same grid, and it's often free to move a flexible job into a cleaner hour. Timing is the lever almost nobody pulls. This is exactly what makes Carbon-Aware Scheduling of Flexible Loads possible: don't necessarily use less — use it when it's clean.
4. The math
4.1 The equation
Carbon intensity in hour t is the generation-weighted average emission factor:
Σ_f gen_f(t) · EF_f
CI(t) = ---------------------- (gCO2/kWh)
Σ_f gen_f(t)
gen_f(t) = generation (MWh) of fuel f in hour t (the weight)
EF_f = lifecycle emission factor of fuel f (gCO2/kWh)
This is a weighted average (see Weighted Averages & Aggregation): each fuel's dirtiness EF_f is weighted by how much that fuel is generating, gen_f(t). A fuel that produces nothing that hour contributes nothing.
Unit note:
gen_fin MWh andEF_fin gCO₂/kWh both scale linearly, so the ratio comes out in gCO₂/kWh cleanly — the MWh↔kWh factor cancels in numerator and denominator.
4.2 Worked example — the ~6× swing
Same grid, same total kWh delivered, two different hours.
Hour A — windy night (clean). Generation: NUCLEAR 5000, WIND 4000, GAS 1000 MWh.
numerator = 5000·12 + 4000·11 + 1000·490
= 60,000 + 44,000 + 490,000 = 594,000
denominator = 5000 + 4000 + 1000 = 10,000
CI_A = 594,000 / 10,000 = 59.4 gCO2/kWh → CLEAN
Hour B — evening peak (dirty). Generation: GAS 6000, COAL 1500, OIL 500, NUCLEAR 5000 MWh.
numerator = 6000·490 + 1500·820 + 500·650 + 5000·12
= 2,940,000 + 1,230,000 + 325,000 + 60,000 = 4,555,000
denominator = 6000 + 1500 + 500 + 5000 = 13,000
CI_B = 4,555,000 / 13,000 = 350.4 gCO2/kWh → DIRTY
The punchline:
CI_B / CI_A = 350.4 / 59.4 ≈ 5.9× (~6× swing)
Same grid. Same wires. A 1 kWh job emits 59.4 g if it runs in Hour A but 350.4 g in Hour B — for doing the exact same work. Move the job, cut ~83% of its carbon, change nothing else.
5. Real code
Computing CI(t) from a real fuel-mix dataframe (e.g. EIA hourly data). The tricky parts are defensive: zero-generation hours (divide-by-zero → NaN), and unknown fuels that must fall back to the OTHER factor.
"""Compute grid carbon intensity CI(t) from an hourly fuel-mix dataframe.
Input df: one row per (hour, fuel) with columns [timestamp, fuel, gen_mwh].
Output: one CI value (gCO2/kWh) per hour.
"""
import numpy as np
import pandas as pd
# IPCC AR5 lifecycle emission factors (gCO2/kWh). Keys are UPPERCASE fuel names.
EMISSION_FACTORS = {
"COAL": 820, "OIL": 650, "GAS": 490, "OTHER": 230,
"SOLAR": 48, "HYDRO": 24, "NUCLEAR": 12, "WIND": 11,
}
UNKNOWN_FUEL_EF = EMISSION_FACTORS["OTHER"] # unrecognized fuel -> OTHER
def emission_factor(fuel: str) -> float:
"""Look up EF for a fuel; unknown fuels fall back to OTHER (230)."""
return EMISSION_FACTORS.get(str(fuel).strip().upper(), UNKNOWN_FUEL_EF)
def carbon_intensity(df: pd.DataFrame) -> pd.Series:
"""Generation-weighted CI(t) per hour: sum(gen*EF) / sum(gen)."""
d = df.copy()
# Map each row's fuel to its emission factor (unknown -> OTHER).
d["ef"] = d["fuel"].map(emission_factor)
# Weighted numerator (gCO2) and denominator (MWh) per hour.
d["weighted"] = d["gen_mwh"] * d["ef"]
grouped = d.groupby("timestamp").agg(
num=("weighted", "sum"),
den=("gen_mwh", "sum"),
)
# GUARD: zero total generation would divide by zero -> NaN, not a crash.
ci = grouped["num"] / grouped["den"].replace(0, np.nan)
ci.name = "ci_gco2_per_kwh"
return ci # NaN marks hours with no reported generation (data gap)
if __name__ == "__main__":
# Two hours from the worked example above.
rows = [
# windy night -> ~59.4 (CLEAN)
("2026-01-15T03:00", "NUCLEAR", 5000),
("2026-01-15T03:00", "WIND", 4000),
("2026-01-15T03:00", "GAS", 1000),
# evening peak -> ~350.4 (DIRTY)
("2026-01-15T18:00", "GAS", 6000),
("2026-01-15T18:00", "COAL", 1500),
("2026-01-15T18:00", "OIL", 500),
("2026-01-15T18:00", "NUCLEAR", 5000),
# a data-gap hour: zero generation -> should be NaN, not a crash
("2026-01-15T04:00", "WIND", 0),
]
df = pd.DataFrame(rows, columns=["timestamp", "fuel", "gen_mwh"])
print(carbon_intensity(df).round(1))
# 2026-01-15T03:00 59.4
# 2026-01-15T04:00 NaN <- guarded divide-by-zero
# 2026-01-15T18:00 350.4
The load-bearing details: .get(..., OTHER) handles unknown fuels, and .replace(0, np.nan) turns a zero-generation hour into a clean NaN (a labeled data gap) instead of a crash or a misleading inf.
6. Real-world example
Scenario: a nightly ML retraining job on ISO New England (ISO-NE).
A team retrains a recommendation model every night. The job draws about 1.2 kWh of electricity (facility-adjusted). It currently kicks off at 6:00 p.m. because that's when the data pipeline finishes — right in the dirty evening peak.
Using the two worked hours as stand-ins for "as-scheduled" vs "clean window":
| When it runs | CI (gCO₂/kWh) | Energy (kWh) | Emissions (gCO₂) |
|---|---|---|---|
| 6:00 p.m. (evening peak) | 350.4 | 1.2 | 420.5 g |
| 3:00 a.m. (windy night) | 59.4 | 1.2 | 71.3 g |
savings = 420.5 - 71.3 = 349.2 gCO2 per run (~83% cut)
over a year (365 nightly runs):
349.2 g × 365 = 127,458 g ≈ 127 kgCO2 saved / year
...for ONE small nightly job, by moving the clock only. Zero code change to the model.
Now scale that: a company running thousands of flexible batch jobs (ETL, encoding, training, report generation) on EIA-backed grids (ISO-NE, PJM, MISO, CAISO, NYISO, ERCOT, SPP) can shift a large fraction of them into clean windows. Same hardware, same output, a large chunk of carbon gone — purely from timing. That is the business case for carbon-aware scheduling.
7. Interview questions companies actually ask
Q1 [easy] (Google, Microsoft) "Is grid carbon intensity constant? Why does it change?"
A No. It's a generation-weighted average of the fuels running each hour, and that mix
changes constantly. Windy nights and sunny middays are dominated by wind/solar/nuclear
(clean); evening peaks fire gas/coal/oil peakers (dirty). Same grid, ~6× swing in a day.
Q2 [easy] (Amazon Sustainability) "What are the units of carbon intensity and what do
high/low mean?"
A gCO2 per kWh. Low = clean electricity (each kWh emits little CO2); high = dirty. It's an
intensity, not a total — multiply by kWh used to get actual emissions.
Q3 [medium] (Google, WattTime) "Write the formula for CI(t) and explain each term."
A CI(t) = Σ_f gen_f(t)·EF_f / Σ_f gen_f(t). It's a weighted average: each fuel's emission
factor EF_f (gCO2/kWh) weighted by its generation gen_f(t) (MWh) that hour. A fuel making
zero power contributes zero.
Q4 [medium] (Microsoft, startups) "Where are the clean windows in a typical day, and why?"
A Overnight (strong wind + low demand) and, in solar-heavy grids, the midday solar dip.
The dirtiest window is the evening peak: demand is highest exactly when solar has set, so
gas/oil peakers cover the gap.
Q5 [medium] (Meta, Amazon) "If a task uses fixed energy, how can you cut its carbon without
using less energy?"
A Run it when CI is low. emissions = kWh × CI, and CI swings ~6× on the same grid, so
shifting a flexible job from the evening peak to a windy night cuts ~80%+ of its carbon
with zero change to the workload. This is carbon-aware scheduling.
Q6 [medium] (data-center teams) "Your CI computation returns inf or NaN for some hours. Why,
and how do you handle it?"
A Those are hours with zero (or missing) reported generation, so the denominator Σ gen is 0.
Guard the division: replace 0 with NaN and treat NaN as a labeled data gap (carry forward,
interpolate, or skip) rather than a real clean/dirty reading.
Q7 [hard] (Google, CAISO/grid) "Coal is only ~1.7× the emission factor of gas, yet CI can
swing 6×. How?"
A The swing comes from the MIX, not one fuel's factor. Clean hours are dominated by wind
(11) and nuclear (12) — near-zero contributors — so the weighted average is tiny. Dirty
hours drop those and pile on gas/coal/oil, all 490-820. Changing WHICH fuels dominate,
not just their individual factors, drives the multiplier.
Q8 [hard] (ERCOT/CAISO ops, WattTime) "You computed today's average CI. Should you use it to
decide whether to add a new load right now?"
A Careful: the whole-mix AVERAGE isn't the same as the MARGINAL generator your new load
actually turns on (often gas). For a scheduling/shift decision, marginal signals
(Electricity Maps, WattTime) can be more accurate. Report the transparent average but
disclose the marginal caveat. (See the Emission Factors article.)
8. When to use / tradeoffs
USE hourly CI(t) when:
✓ you have flexible / deferrable workloads (batch jobs, training, encoding, ETL)
✓ you can get hourly fuel-mix data (EIA, ISO-NE, Electricity Maps, WattTime)
✓ you want a transparent, auditable, reproducible carbon signal
LIMITS / honest caveats:
✗ AVERAGE ≠ MARGINAL: CI(t) here is whole-mix average intensity. A decision to ADD load
arguably displaces the marginal unit (often gas), which can differ a lot. For shift
decisions, consider a marginal signal. (See Emission Factors & Accounting.)
✗ Data latency & gaps: real feeds lag and have holes (the NaN case). Forecasts needed to
schedule the FUTURE, and forecasts have error.
✗ Lifecycle vs combustion-only factors differ; pick one basis and disclose it.
✗ Not all load is flexible — a live user request must run NOW, dirty hour or not.
✗ Transmission/imports: a balancing authority imports power whose CI it may not fully see.
DON'T over-claim: shifting load reduces YOUR reported emissions on the chosen basis; whether
it reduces GRID emissions depends on the marginal displacement. Be intellectually honest.
9. Summary + related articles
- Grid carbon intensity
CI(t)is not constant — it's a generation-weighted average of the fuel mix, which changes every hour. - Emission factors span ~11 (wind) to 820 (coal) gCO₂/kWh; the mix is what drives the swing.
- Clean windows recur overnight (wind) and midday (solar); the dirty window is the evening peak.
- The worked example shows a ~6× swing (59.4 → 350.4) on the same grid, same kWh.
- Key insight:
emissions = kWh × CI— timing (the CI term) is a huge, usually-free lever. - That is exactly what makes carbon-aware scheduling possible.
- Guard your code against zero-generation hours (NaN) and unknown fuels (→ OTHER).
Related: Emission Factors & Carbon Accounting · Measuring the Energy & Carbon of AI (the A/E Metric) · Weighted Averages & Aggregation · Carbon-Aware Scheduling of Flexible Loads
Resources
- EIA Open Data API (hourly electricity, fuel mix by balancing authority) — https://www.eia.gov/opendata/
- IPCC AR5 WG3 Annex III (lifecycle emission factors) — https://www.ipcc.ch/report/ar5/wg3/
- Electricity Maps (real-time & historical CI) — https://www.electricitymaps.com/
- WattTime (marginal emissions signal) — https://www.watttime.org/
- ISO New England (real-time fuel mix) — https://www.iso-ne.com/
- Google carbon-intelligent computing — https://blog.google/inside-google/infrastructure/data-centers-work-harder-sun-shines-wind-blows/