Skip to main content

gridcarbon

Python client for the gridcarbon.dev API — carbon intensity of electricity grids across 45 zones in Europe, the United States and Great Britain.

  • Zero runtime dependencies. Standard library only (urllib.request).
  • Fully typed, ships py.typed.
  • Python 3.9+.
  • Synchronous and small: four methods, three value types.

Status: pre-alpha. History is uneven by region: Great Britain from 2017-09, the 11 US zones from 2019-01, and the 33 European zones from 2026-05-25 — the European archive is shallow because it has not been backfilled yet, not because ENTSO-E stops there. The API is unauthenticated and free, and the shape of these responses may still change.

Install

pip install gridcarbon

No API key. No sign-up.

60-second example

from gridcarbon import GridCarbon

gc = GridCarbon()

de = gc.latest("DE")
print(f"{de.zone}  {de.gco2eq_kwh} gCO2eq/kWh  at {de.ts:%Y-%m-%d %H:%M UTC}")
print(f"published {de.age.total_seconds() / 3600:.1f}h ago, lifecycle={de.is_lifecycle}")

# Cleanest grid right now, GB excluded because it is measured differently.
comparable = [r for r in gc.latest() if r.is_lifecycle]
cleanest = min(comparable, key=lambda r: r.gco2eq_kwh)
print(f"cleanest of {len(comparable)} comparable zones: "
      f"{cleanest.zone} at {cleanest.gco2eq_kwh} gCO2eq/kWh")

# Last 24 hours in France.
fr = gc.series("FR")
print(f"FR: {fr.count} points, avg {fr.average():.1f} gCO2eq/kWh, truncated={fr.truncated}")

Real output, run 2026-08-26 ~02:50 UTC:

DE  371.4 gCO2eq/kWh  at 2026-08-26 01:00 UTC
published 1.9h ago, lifecycle=True
cleanest of 44 comparable zones: CH at 18.6 gCO2eq/kWh
FR: 22 points, avg 46.0 gCO2eq/kWh, truncated=False

Two things that will bite you

1. GB is not comparable with the other 44 zones

Every zone except GB is computed here from generation mix using IPCC AR5 lifecycle emission factors. GB comes straight from NESO, which publishes operational (combustion-only) factors — wind, solar, nuclear and hydro are counted as zero, and nothing upstream of the smokestack is counted at all.

GB is therefore structurally lower than a lifecycle number for the same physical grid. Putting GB in a league table with DE is comparing two different accounting systems.

Every Reading and Zone exposes .is_lifecycle so you can filter:

comparable = [r for r in gc.latest() if r.is_lifecycle]   # 44 zones, GB dropped

In the example above GB was sitting at 114 gCO2eq/kWh — it would have won "cleanest grid" outright on the raw number. It is excluded not because the number is wrong, but because it answers a different question. Compare GB to GB.

2. "Latest" means most recently published, not now

Freshness varies enormously by source. Measured 2026-08-26 02:52 UTC:

GB        ts=2026-08-26 01:30Z  age=  1.4h
US-PJM    ts=2026-08-25 02:00Z  age= 24.9h

Europe typically runs 2–4 hours behind; US zones run 11–28 hours behind. That is normal upstream publishing lag, not a fault. Always check reading.age before you present a value as current, and never label a US figure "live".

API

GridCarbon(base_url="https://api.gridcarbon.dev", timeout=10.0,
           user_agent="gridcarbon-python/0.1.1")

The client is stateless, holds no connection, and is safe to share between threads. If you build something on top of this, please append your own token to the user agent ("gridcarbon-python/0.1.1 myapp/2.1") so the operator can tell clients apart.

gc.health() -> bool

print(gc.health())
True

A cheap liveness probe against /v1/health. Returns True when the API reports itself healthy, and raises the usual errors (below) when it cannot be reached at all.

gc.zones() -> list[Zone]

zones = gc.zones()
print(f"{len(zones)} zones")
for z in zones[:3] + [z for z in zones if z.zone == "GB"]:
    print(f"  {z.zone:<9} {z.name:<26} {z.source:<8} {z.resolution_min}min  lifecycle={z.is_lifecycle}")
45 zones
  AT        Austria                    entsoe   60min  lifecycle=True
  BE        Belgium                    entsoe   60min  lifecycle=True
  CH        Switzerland                entsoe   60min  lifecycle=True
  GB        Great Britain              uk-neso  30min  lifecycle=False

Zone: .zone, .name, .source (entsoe | eia | uk-neso), .resolution_min, .is_lifecycle.

gc.latest() / gc.latest(zone)

gc.latest() returns list[Reading], one per zone. gc.latest("DE") returns a single Reading and raises UnknownZone on an unknown id. Zone ids are case-insensitive.

Reading is a frozen dataclass:

attribute type meaning
.zone str zone id — always populated
.ts datetime timezone-aware UTC instant the data describes
.gco2eq_kwh float grams CO₂-equivalent per kWh
.method str computed:v1, upstream:uk-neso:actual, upstream:uk-neso:forecast
.is_lifecycle bool False for GB — see above
.is_forecast bool True for NESO forecast rows
.age timedelta how long ago .ts was

The wire format is asymmetric: /v1/intensity/latest items carry zone, while /v1/intensity items do not (it lives on the envelope). This SDK normalises that away, so .zone is always set no matter which call produced the reading.

gc.series(zone, start=None, end=None, *, allow_truncated=False) -> Series

start and end accept a datetime or an ISO-8601 string. Naive datetimes are read as UTC (never local time); aware ones are converted. The window is half-open: [start, end). Defaults to the last 24 hours.

s = gc.series("DE", start="2026-08-22T00:00:00Z", end="2026-08-22T06:00:00Z")
print(s)
for r in s:
    print(f"  {r.ts:%H:%M}Z  {r.gco2eq_kwh:6.1f}  {r.method}")
Series(zone='DE', count=6, start=2026-08-22T00:00:00+00:00, end=2026-08-22T06:00:00+00:00)
  00:00Z   382.6  computed:v1
  01:00Z   380.2  computed:v1
  02:00Z   369.7  computed:v1
  03:00Z   350.7  computed:v1
  04:00Z   318.7  computed:v1
  05:00Z   283.1  computed:v1

Series behaves like a read-only sequence — len(), indexing, slicing, iteration — plus .zone, .start, .end, .unit, .count, .truncated, .note, .is_lifecycle and .average().

Timestamps parse strictly, on your machine, before anything is sent. This matters: the API silently ignores an unparseable from and substitutes its own default window, which would hand you 24 hours of data while you believed you had asked for a year.

ValueError: not a valid ISO-8601 timestamp: 'last tuesday'

Truncated series raise, they do not warn

The API caps a response at 5000 points and sets truncated: true. A silently short series is the worst possible failure for this data — you get a plausible number that is simply wrong. So Series refuses to hand over its readings until you acknowledge the cap:

Series for zone 'DE' is TRUNCATED: the API returned 5000 points and capped the result,
so this is NOT the full window [2026-08-21T00:00:00+00:00 .. 2026-08-26T00:00:00+00:00).
Iterating it would silently under-report.
Either narrow the window with start=/end=, or acknowledge the cap explicitly:
    gc.series('DE', ..., allow_truncated=True)
    # or: series.acknowledge_truncation()
Server note: Result capped at 5000 points. Narrow the window with 'from'/'to' to get the rest.

Iterating, indexing, slicing, .readings and .average() all raise TruncatedSeriesError. .truncated, .count, .note, len() and repr() stay readable so you can diagnose the problem without acknowledging anything.

(That transcript is from the unit-test fixture, not a live call: at 45 zones and hourly resolution, no window reachable today is anywhere near 5000 points. The path is covered by tests so it will behave correctly once history is long enough to hit it.)

Errors

All errors derive from GridCarbonError.

exception when
UnknownZone latest(zone) got a 404 — subclass of ApiError, carries .zone
ApiError any other non-2xx — carries .status, .message, .payload, .url
GridCarbonTimeout request exceeded timeout — also a builtin TimeoutError
NetworkError DNS/TLS/refused — never reached an HTTP response
TruncatedSeriesError reading a capped Series without acknowledging it
from gridcarbon import GridCarbon, UnknownZone, GridCarbonTimeout

try:
    gc.latest("ZZ")
except UnknownZone as e:
    print(f"UnknownZone: zone={e.zone!r} status={e.status} message={e.message!r}")
UnknownZone: zone='ZZ' status=404 message='unknown or empty zone: ZZ'
GridCarbonTimeout: request to https://api.gridcarbon.dev/v1/intensity/latest?zone=DE timed out after 0.001s

One asymmetry to know about: series() does not raise UnknownZone. The /v1/intensity endpoint answers an unknown zone with 200 and an empty array, which is indistinguishable from a window that simply has no data, so the SDK will not guess:

series('ZZ') -> count=0 truncated=False bool=False

Validate against gc.zones() if you need certainty.

Polling

latest carries Cache-Control: max-age=300, and the underlying data only changes hourly. Polling faster returns identical numbers. One limit is configured: 60 requests per minute per IP, which returns 429 with a Retry-After header. Cloudflare's limiter is deliberately permissive and eventually consistent — each isolate counts separately — so bursts well above that usually succeed. Measured 2026-09-01: 150 requests in 14 seconds from one IP, no 429. Treat 60/min as the intent, not the ceiling, and do not build against the headroom. It applies to every request — the Worker runs in front of the cache — so honour the Cache-Control headers in your own client.

Development

python -m unittest discover -s tests -t .     # 83 offline tests, no network
GRIDCARBON_LIVE=1 python -m unittest tests.test_live_smoke -v   # 10 live tests

The offline suite replaces gridcarbon._http.urlopen with a fake and asserts against payloads captured verbatim from the live API. The live suite is skipped unless GRIDCARBON_LIVE=1, and asserts only on shape and invariants, never on values.

Is the data actually fresh?

health() only tells you the API answered. status() tells you whether collection is keeping up, per upstream — which is the question that matters before you act on a number.

st = gc.status()
if not st.ok:
    for s in st.stale_sources():
        print(f"{s.source} is {s.stalest_lag_hours}h behind (normal is under {s.stale_after_hours}h)")

Thresholds differ by source on purpose: European feeds publish within hours, EIA is routinely most of a day behind on its own schedule.

Attribution

Use of the data carries an attribution obligation. If you publish figures derived from this SDK, credit the upstream sources:

  • ENTSO-E Transparency Platform — European zones.
  • U.S. Energy Information Administration (EIA) — US zones.
  • NESO Carbon Intensity API — the GB zone.

The EIA does not endorse this project, its methodology, or anything derived from it. Neither do ENTSO-E or NESO.

Licence

  • SDK source code: MIT — see LICENSE.
  • Data values served by the API and returned through this SDK: CC BY 4.0 — see DATA-LICENSE.md for the attribution notice and upstream terms.

Author: gupeng <hello@gridcarbon.dev> · gridcarbon.dev

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

gridcarbon-0.1.1.tar.gz (25.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

gridcarbon-0.1.1-py3-none-any.whl (22.5 kB view details)

Uploaded Python 3

File details

Details for the file gridcarbon-0.1.1.tar.gz.

File metadata

  • Download URL: gridcarbon-0.1.1.tar.gz
  • Upload date:
  • Size: 25.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for gridcarbon-0.1.1.tar.gz
Algorithm Hash digest
SHA256 ce6be2968ac856ddde1666588f199248ac76a1671c874b93383f1f517a84ee6b
MD5 85249da7f5a1a1f601456f39ed2853a2
BLAKE2b-256 5146f990804b1c7f541132c182ad9924e40451ac731c9187c34d5437bd7e3d22

See more details on using hashes here.

File details

Details for the file gridcarbon-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: gridcarbon-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 22.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for gridcarbon-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 d1cca8a7adf40a73b7681275c5e849f4884fb663d9482ffdb5eac943859ae083
MD5 e9c89c444d3a6abafecaa494bc64227f
BLAKE2b-256 995c09d3078a325d8adae7d15da6df36ce2abc929c57ecaffc69ea29fe9fc800

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page