← Back to Learning Hub

Why Grid Carbon Intensity Varies Hour to Hour

Grid carbon intensityEmission factorsIntermediate15 min

By: Anacodic Team

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):

FuelEF (gCO₂/kWh)Notes
Coal820Dirtiest common source
Oil650Peaker / backup
Gas490The usual "swing" fuel
Other≈230Biomass/mixed/unknown
Solar48Lifecycle (panel manufacturing)
Hydro24Low, some reservoir methane
Nuclear12Very low carbon
Wind11Cleanest 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 dayTypical driverEffect on CI
Overnight (00–05)Low demand, wind often strong, nuclear steadyClean (a common clean window)
Morning ramp (06–09)Demand rises, gas comes onRising
Midday (11–15)Solar peaks in sunny regionsDip (clean in CA/TX)
Evening peak (17–21)Demand peak, solar gone, peakers fireDirty (worst window)
Late night (22–24)Demand falls, peakers offFalling

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_f in MWh and EF_f in 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 runsCI (gCO₂/kWh)Energy (kWh)Emissions (gCO₂)
6:00 p.m. (evening peak)350.41.2420.5 g
3:00 a.m. (windy night)59.41.271.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.

  • 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 × CItiming (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