# Keeling curve

> Monthly CO₂ at Mauna Loa since 1958: NOAA's record and the functions that read it.

Page: https://sciencehub.sh/sciencehub-team/keeling-curve

- Project: [sciencehub-team/keeling-curve](https://sciencehub.sh/sciencehub-team/keeling-curve.md)
- Authors: ScienceHub
- Subjects: atmospheric-science
- Topics: carbon-dioxide, climate, time-series, dataset
- 0 stars, 0 forks
- DOI: https://doi.org/10.83371/sh.2610.nfbxy
- Source: https://sciencehub.sh/sciencehub-team/keeling-curve/source
- Updated 13 Oct 2026

## Notebook

Text the notebook's authors wrote, its cells as Python, and the outputs it produced when it last ran in a browser and was saved.

The script header: the dependencies the notebook declares (PEP 723) and the app it defines.

```python
# /// script
# requires-python = ">=3.11"
# dependencies = ["marimo>=0.25.1", "numpy", "pandas", "matplotlib"]
# ///

import marimo

__generated_with = "0.25.1"
app = marimo.App(width="medium", app_title="The Keeling curve")
```

```python
import numpy as np
import pandas as pd

DATA = "data/co2_mm_mlo.csv"
```

```python
def load_co2(path: str = DATA) -> pd.DataFrame:
    """Monthly mean CO₂ in the air at Mauna Loa, Hawaii, in ppm, from March 1958.

    Columns: year, month, decimal_date, average, deseasonalized, ndays, sdev,
    unc. NOAA fills months without measurements by interpolation and marks
    them with a negative ndays.
    """
    co2 = pd.read_csv(path, comment="#")
    return co2.rename(columns={"decimal date": "decimal_date"})
```

```python
def yearly_mean(path: str = DATA) -> pd.Series:
    """The deseasonalized concentration averaged over each complete year, in ppm, by year."""
    co2 = load_co2(path)
    complete = co2.groupby("year").filter(lambda year: len(year) == 12)
    return complete.groupby("year")["deseasonalized"].mean()
```

```python
def growth_rate(start_year: int, end_year: int, path: str = DATA) -> float:
    """Average growth of CO₂ between two complete years, in ppm a year.

    >>> round(growth_rate(1960, 1970), 2)
    0.88
    """
    yearly = yearly_mean(path)
    if start_year not in yearly.index or end_year not in yearly.index:
        raise ValueError(f"Both years need twelve months of data; there are {yearly.index.min()} to {yearly.index.max()}.")
    return float((yearly[end_year] - yearly[start_year]) / (end_year - start_year))
```

```python
def seasonal_cycle(path: str = DATA) -> pd.Series:
    """Each month's mean departure from the deseasonalized trend, in ppm: the breath of the northern forests."""
    co2 = load_co2(path)
    return (co2["average"] - co2["deseasonalized"]).groupby(co2["month"]).mean()
```

```python
import marimo as mo
import matplotlib.pyplot as plt
```

### The Keeling curve

In March 1958 Charles David Keeling began measuring carbon dioxide in the
air on the slope of Mauna Loa, Hawaii, far from cities and forests. The
record has run almost without a break since; NOAA's Global Monitoring
Laboratory continues it today. It shows two things at once: a seasonal
rhythm as the land plants of the northern hemisphere grow and decay, and a
rise that has not paused in any year.

This project is the data and the notebook together. The file
`data/co2_mm_mlo.csv` is NOAA's monthly record as published; the functions
below read it, and anyone can call them from Python with
`sciencehub.load("sciencehub-team/keeling-curve")`.

```python
co2 = load_co2()
first_year, last_year = int(co2["year"].min()), int(co2["year"].max())
```

```python
years = mo.ui.range_slider(
    start=first_year,
    stop=last_year,
    step=1,
    value=[first_year, last_year],
    label="Years",
    full_width=True,
)
years
```

```python
_start, _end = years.value
_shown = co2[(co2["year"] >= _start) & (co2["year"] <= _end)]
fig, ax = plt.subplots(figsize=(7, 3.8))
ax.plot(_shown["decimal_date"], _shown["average"], color="#0969da", lw=1, label="monthly mean")
ax.plot(_shown["decimal_date"], _shown["deseasonalized"], color="#1f2328", lw=2, label="without the seasons")
ax.set_xlabel("year")
ax.set_ylabel("CO₂ (ppm)")
ax.legend(frameon=False, loc="upper left")
for _side in ("top", "right"):
    ax.spines[_side].set_visible(False)
fig.tight_layout()

_rise = _shown["deseasonalized"].iloc[-1] - _shown["deseasonalized"].iloc[0]
mo.vstack(
    [
        fig,
        mo.md(
            f"From {_start} to {_end} the concentration rose by **{_rise:.1f} ppm**, "
            f"from {_shown['deseasonalized'].iloc[0]:.1f} to {_shown['deseasonalized'].iloc[-1]:.1f} ppm."
        ),
    ]
)
```

Output:

```text
From 1958 to 2026 the concentration rose by 115.1 ppm, from 314.4 to 429.6 ppm.
```

#### The rise is speeding up

The growth rate is the average yearly rise over a decade. In the 1960s the
concentration grew by under one ppm a year; it now grows by more than two.

```python
_yearly = yearly_mean()
_decades = list(range(int(_yearly.index.min()) // 10 * 10 + 10, int(_yearly.index.max()), 10))
_rates = [growth_rate(_d - 10, _d) if _d - 10 in _yearly.index else np.nan for _d in _decades]
fig2, ax2 = plt.subplots(figsize=(7, 3))
ax2.bar([f"{_d - 10}s" for _d in _decades], _rates, color="#1f2328")
ax2.set_ylabel("ppm a year")
for _side in ("top", "right"):
    ax2.spines[_side].set_visible(False)
fig2.tight_layout()
mo.vstack([fig2, mo.md(f"Over the last complete decade: **{_rates[-1]:.2f} ppm a year**.")])
```

Output:

```text
Over the last complete decade: 2.41 ppm a year.
```

#### The seasons

Every year the concentration falls from May to October, while northern
forests and crops take up carbon, and rises again through winter. The
swing at Mauna Loa is about six ppm.

```python
_cycle = seasonal_cycle()
fig3, ax3 = plt.subplots(figsize=(7, 2.8))
ax3.bar(["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], _cycle.values, color=["#9a6700" if _v > 0 else "#0969da" for _v in _cycle.values])
ax3.axhline(0, color="#1f2328", lw=0.8)
ax3.set_ylabel("ppm from the trend")
for _side in ("top", "right"):
    ax3.spines[_side].set_visible(False)
fig3.tight_layout()
fig3
```

![Figure (image/png)](https://plvp7twgybmoyw2svb6yojv4vv.notebooks.sciencehubusercontent.com/_outputs/eyJuYW1lc3BhY2VJZCI6Im5zLXdicjZya2tkOWZqMG1neGUiLCJwcm9qZWN0SWQiOiJwcm9qLXRzNGI2ejBwYjFmeDR2M2MiLCJub3RlYm9va0lkIjoibmItdmhmM2hxNjdtYjJtdjB3bSIsImhhc2giOiJhMDYzNzMzNjgyMDE4ZTU3MjNhZTY1NjcxYzIxYmU0OSJ9.0.2eVggfMnjCQjQ_pLXeoDMMRZG_5mVsyj-t8wYoQVwZI)

#### Data, limits and references

**Data.** `data/co2_mm_mlo.csv`, the monthly means from NOAA's Global
Monitoring Laboratory as published on 9 October 2026, unchanged. Values
before May 1974 are from the Scripps Institution of Oceanography. Months
without measurements are interpolated by NOAA (negative `ndays`). From
December 2022 to July 2023, after the eruption of Mauna Loa, the
measurements come from Maunakea, 21 miles to the north.

**Limits.** One site measures the air over the central Pacific, which
represents the well-mixed atmosphere well but not every region. The
growth rates here are differences of yearly means, not the smoothed rates
NOAA publishes.

**Cite the data.** Lan, X., Tans, P. and Thoning, K. W.: Trends in
globally-averaged CO₂ determined from NOAA Global Monitoring Laboratory
measurements, https://gml.noaa.gov/ccgg/trends/; Keeling, C. D. et al.,
Scripps Institution of Oceanography, https://scrippsco2.ucsd.edu/. NOAA
makes the data freely available and asks for this credit.

Keeling, C. D. (1960). The concentration and isotopic abundances of carbon
dioxide in the atmosphere. *Tellus* 12(2), 200–203.

## Files

- [data/co2_mm_mlo.csv](https://sciencehub.sh/sciencehub-team/keeling-curve/raw/data/co2_mm_mlo.csv) (39 kB)
- [README.md](https://sciencehub.sh/sciencehub-team/keeling-curve/raw/README.md) (1.2 kB)

## Functions

Load them in Python with `sciencehub.load("sciencehub-team/keeling-curve")`.

- `load_co2(path: str = DATA) -> pd.DataFrame`: Monthly mean CO₂ in the air at Mauna Loa, Hawaii, in ppm, from March 1958.
- `yearly_mean(path: str = DATA) -> pd.Series`: The deseasonalized concentration averaged over each complete year, in ppm, by year.
- `growth_rate(start_year: int, end_year: int, path: str = DATA) -> float`: Average growth of CO₂ between two complete years, in ppm a year.
- `seasonal_cycle(path: str = DATA) -> pd.Series`: Each month's mean departure from the deseasonalized trend, in ppm: the breath of the northern forests.

## Versions

- [ver_01M4V36RH5ZV0RGFPSNS52YCXV](https://sciencehub.sh/sciencehub-team/keeling-curve/history/ver_01M4V36RH5ZV0RGFPSNS52YCXV), 13 Oct 2026: Added README.md (current)
- [ver_01M4V366RQREJ1PH45C9S6FHZR](https://sciencehub.sh/sciencehub-team/keeling-curve/history/ver_01M4V366RQREJ1PH45C9S6FHZR), 13 Oct 2026: Added data/co2_mm_mlo.csv
- [ver_01M4V33JMXYN98WQQQN9JBW5G5](https://sciencehub.sh/sciencehub-team/keeling-curve/history/ver_01M4V33JMXYN98WQQQN9JBW5G5), 13 Oct 2026: Created

## Cite

ScienceHub (2026). Keeling curve \[Computer software\]. ScienceHub. https://doi.org/10.83371/sh.2610.nfbxy

```bibtex
@software{keeling_curve_2026,
  author = {ScienceHub},
  title = {Keeling curve},
  year = {2026},
  publisher = {ScienceHub},
  doi = {10.83371/sh.2610.nfbxy}
}
```
