US Fuel Prices Report

12 March 2026

ML and Cursor AI

1. Process

This report describes how fuel price data are obtained and presents the results.

Data Acquisition

  • AAA (American Automobile Association): Daily retail gasoline prices by state are scraped from gasprices.aaa.com/todays-state-averages. The data are updated daily by the Oil Price Information Service (OPIS) in cooperation with WEX, Inc., surveying up to 120,000 gas stations. Data are stored in Parquet files under data/, one file per state.

  • EIA (U.S. Energy Information Administration): Weekly retail gasoline prices are fetched via the EIA Open Data API. A free API key is required for registration. Data are stored in Parquet files under eia_data/, one file per state.

    EIA data description and limitations: The EIA publishes weekly retail gasoline prices (regular grade, all formulations, including taxes) from its Motor Gasoline Price Survey. Data are released weekly, typically on Mondays. A key limitation is that EIA provides full historical time series for only ten geographic areas: California, Colorado, Florida, Massachusetts, Minnesota, New York, Ohio, Texas, Washington, and the United States (national average). All other states are not included in the EIA retail gasoline price series. AAA data cover all 51 states (including DC) but are daily snapshots without the long historical series that EIA provides for these ten.

  • Scheduling: The AAA scraper runs daily via Windows Task Scheduler. The EIA fetcher runs weekly to align with EIA release frequency.

  • Storage: All data are stored in Parquet format by state. Each run appends new observations and deduplicates by date.

Data sources:

2. Full History

The following chart shows regular gasoline prices over the full available history for each state. EIA data provide the time series; AAA states with only a single observation are excluded from this view.

3. By State 2026

Fuel prices since January 1, 2026 are displayed using facet_wrap(): one panel per state (only states with 2+ data points), with all panels sharing the same y-axis scale for direct comparison.

Summary Table

State Price start 2026 Price latest State house State senate
California 4.03 5.37 Democratic Democratic
Colorado 2.14 3.60 Democratic Democratic
Florida 2.74 3.72 Republican Republican
Massachusetts 2.86 3.45 Democratic Democratic
Minnesota 2.68 3.30 Split Democratic
New York 3.06 3.52 Democratic Democratic
Ohio 2.54 3.43 Republican Republican
Texas 2.28 3.26 Republican Republican
United States 2.80 3.50 N/A N/A
Washington 3.71 4.74 Democratic Democratic

EIA vs AAA (10 States)

EIA data. The U.S. Energy Information Administration (EIA) publishes weekly retail gasoline prices from its survey of gasoline retailers. These are official government statistics and are widely used for policy and research. Limitations: EIA data are weekly (not daily), lag by several days, and are available only for a limited set of areas.

Geographic coverage. The EIA provides full historical series for only ten areas: California, Colorado, Florida, Massachusetts, Minnesota, New York, Ohio, Texas, Washington, and the United States as a whole. All other states rely on AAA (or other) data in this report.

The table below compares EIA and AAA for these ten areas: EIA price at the start of 2026, latest EIA price, latest AAA price, and percent year-to-date change. Dates appear in the column headers when the same for all rows.

State EIA start 2026 EIA latest (2026-03-09) AAA latest (2026-03-12) % YTD change
California 4.03 5.09 5.37 33.1%
Colorado 2.14 3.44 3.60 68.1%
Florida 2.74 3.47 3.72 35.4%
Massachusetts 2.86 3.32 3.45 20.6%
Minnesota 2.68 3.26 3.30 22.9%
New York 3.06 3.40 3.52 15.2%
Ohio 2.54 3.46 3.43 35.4%
Texas 2.28 3.11 3.26 43.4%
United States 2.80 3.50 NA NA
Washington 3.71 4.52 4.74 27.8%
Note: % YTD change = (AAA latest − EIA start 2026) ÷ EIA start 2026 × 100. Compares latest AAA price with EIA price at start of 2026.

4. Single Chart 2026

This chart shows only states that have a series of data (2+ observations) from January 1, 2026. State names are clearly legible in the legend.

Appendix A: Code to Fetch AAA Data

The following Python script scrapes daily state averages from AAA and stores them in Parquet files under data/, one file per state.

"""
Scrape AAA gas prices by state and store in Parquet files.
One Parquet file per state, appending daily snapshots.
"""

import re
from datetime import date
from pathlib import Path

import pandas as pd
import requests
from bs4 import BeautifulSoup

DATA_DIR = Path(__file__).parent / "data"
URL = "https://gasprices.aaa.com/todays-state-averages/"

# Map state names from table to 2-letter codes
STATE_CODES = {
    "Alaska": "AK", "Alabama": "AL", "Arkansas": "AR", "Arizona": "AZ",
    "California": "CA", "Colorado": "CO", "Connecticut": "CT",
    "District of Columbia": "DC", "Delaware": "DE", "Florida": "FL",
    "Georgia": "GA", "Hawaii": "HI", "Iowa": "IA", "Idaho": "ID",
    "Illinois": "IL", "Indiana": "IN", "Kansas": "KS", "Kentucky": "KY",
    "Louisiana": "LA", "Massachusetts": "MA", "Maryland": "MD",
    "Maine": "ME", "Michigan": "MI", "Minnesota": "MN", "Missouri": "MO",
    "Mississippi": "MS", "Montana": "MT", "North Carolina": "NC",
    "North Dakota": "ND", "Nebraska": "NE", "New Hampshire": "NH",
    "New Jersey": "NJ", "New Mexico": "NM", "Nevada": "NV",
    "New York": "NY", "Ohio": "OH", "Oklahoma": "OK", "Oregon": "OR",
    "Pennsylvania": "PA", "Rhode Island": "RI", "South Carolina": "SC",
    "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX",
    "Utah": "UT", "Virginia": "VA", "Vermont": "VT",
    "Washington": "WA", "Wisconsin": "WI", "West Virginia": "WV",
    "Wyoming": "WY",
}


def parse_price(s: str) -> float | None:
    """Parse '$3.598' to 3.598."""
    if not s:
        return None
    m = re.search(r"[\d.]+", s.replace(",", ""))
    return float(m.group()) if m else None


def scrape() -> pd.DataFrame:
    """Scrape today's state averages from AAA."""
    resp = requests.get(URL, headers={"User-Agent": "FuelPrices/1.0"})
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    rows = []
    table = soup.find("table")
    if not table:
        raise ValueError("No table found on page")

    for tr in table.find_all("tr")[1:]:  # skip header
        cells = tr.find_all(["td", "th"])
        if len(cells) < 5:
            continue
        state_name = cells[0].get_text(strip=True)
        # Handle link text like "Alaska" or "District of Columbia"
        link = cells[0].find("a")
        if link:
            state_name = link.get_text(strip=True)
        code = STATE_CODES.get(state_name)
        if not code:
            continue
        regular = parse_price(cells[1].get_text())
        mid_grade = parse_price(cells[2].get_text())
        premium = parse_price(cells[3].get_text())
        diesel = parse_price(cells[4].get_text())
        rows.append({
            "date": date.today().isoformat(),
            "state": state_name,
            "state_code": code,
            "regular": regular,
            "mid_grade": mid_grade,
            "premium": premium,
            "diesel": diesel,
        })

    return pd.DataFrame(rows)


def save_by_state(df: pd.DataFrame) -> None:
    """Save data to one Parquet file per state, appending new rows."""
    DATA_DIR.mkdir(parents=True, exist_ok=True)

    for state_code in df["state_code"].unique():
        state_df = df[df["state_code"] == state_code]
        path = DATA_DIR / f"{state_code}.parquet"

        if path.exists():
            existing = pd.read_parquet(path)
            combined = pd.concat([existing, state_df], ignore_index=True)
            # Deduplicate by date (in case of re-runs)
            combined = combined.drop_duplicates(subset=["date"], keep="last")
            combined = combined.sort_values("date").reset_index(drop=True)
        else:
            combined = state_df

        combined.to_parquet(path, index=False)


def main() -> None:
    df = scrape()
    print(f"Scraped {len(df)} states for {date.today()}")
    save_by_state(df)
    print(f"Saved to {DATA_DIR}/")


if __name__ == "__main__":
    main()

Appendix B: Code to Fetch EIA Data and Store Parquet

The following Python script fetches weekly retail gasoline prices from the EIA Open Data API and stores them in Parquet files under eia_data/. Requires EIA_API_KEY in .env.

"""
Fetch gas price data from the EIA (U.S. Energy Information Administration) API.
Stores weekly retail gasoline prices by state in Parquet files.

Requires: EIA API key (free) from https://www.eia.gov/opendata/register.php
Set via .env (EIA_API_KEY=...) or environment variable.
"""

import argparse
import json
import os
import re
from pathlib import Path

import pandas as pd
import requests
from dotenv import load_dotenv

load_dotenv(Path(__file__).parent / ".env")

EIA_DATA_DIR = Path(__file__).parent / "eia_data"
BASE_URL = "https://api.eia.gov/v2"

# petroleum/pri/gnd = gasoline and diesel retail prices (ground/retail)
# Browse: https://www.eia.gov/opendata/browser/petroleum/pri
ROUTE = "petroleum/pri/gnd/data/"

# Known series IDs for retail regular gasoline (EMM_EPMR_PTE = retail, PTE = all formulations)
# Format: EMM_EPMR_PTE_S{state}_DPG (state) or EMM_EPMR_PTE_NUS_DPG (national)
STATE_CODES = [
    "AK", "AL", "AR", "AZ", "CA", "CO", "CT", "DC", "DE", "FL", "GA", "HI", "IA",
    "ID", "IL", "IN", "KS", "KY", "LA", "MA", "MD", "ME", "MI", "MN", "MO", "MS",
    "MT", "NC", "ND", "NE", "NH", "NJ", "NM", "NV", "NY", "OH", "OK", "OR", "PA",
    "RI", "SC", "SD", "TN", "TX", "UT", "VA", "VT", "WA", "WI", "WV", "WY",
]


def get_api_key() -> str:
    key = os.environ.get("EIA_API_KEY")
    if not key:
        raise SystemExit(
            "Set EIA_API_KEY. Get a free key at https://www.eia.gov/opendata/register.php"
        )
    return key


def fetch_eia(
    api_key: str,
    *,
    frequency: str = "weekly",
    series: str | None = None,
    start: str | None = None,
    end: str | None = None,
    length: int = 5000,
) -> list[dict]:
    """Fetch data from EIA API v2."""
    params = {"api_key": api_key}
    payload = {
        "frequency": frequency,
        "data": ["value"],
        "sort": [{"column": "period", "direction": "desc"}],
        "offset": 0,
        "length": length,
    }
    if series:
        payload["facets"] = {"series": [series]}
    if start:
        payload["start"] = start
    if end:
        payload["end"] = end
    headers = {"X-Params": json.dumps(payload)}
    url = f"{BASE_URL}/{ROUTE}"
    resp = requests.get(url, params=params, headers=headers)
    resp.raise_for_status()
    data = resp.json()
    return data.get("response", {}).get("data", [])


def list_series(api_key: str) -> list[dict]:
    """List available series from the gnd dataset (if facet endpoint exists)."""
    url = f"{BASE_URL}/petroleum/pri/gnd/facet/series/"
    resp = requests.get(url, params={"api_key": api_key})
    if resp.status_code != 200:
        return []
    data = resp.json()
    return data.get("response", {}).get("facets", [])


def fetch_and_save(
    api_key: str,
    start: str | None = None,
    end: str | None = None,
    national_only: bool = False,
) -> None:
    """Fetch retail gasoline prices and save to Parquet by state."""
    EIA_DATA_DIR.mkdir(parents=True, exist_ok=True)

    # Build list of series to fetch
    facets = list_series(api_key)
    if facets:
        # Filter to retail gasoline by state (EPMR = retail, Sxx = state)
        state_series = [
            f["id"] for f in facets
            if "EPMR" in f.get("id", "") and "PTE" in f.get("id", "")
            and "_S" in f.get("id", "") and "NUS" not in f.get("id", "")
        ]
    else:
        state_series = []

    if not state_series:
        # Fallback: construct series IDs (EIA uses this format for many datasets)
        state_series = [f"EMM_EPMR_PTE_S{st}_DPG" for st in STATE_CODES]

    if national_only:
        state_series = ["EMM_EPMR_PTE_NUS_DPG"]

    print(f"Fetching {len(state_series)} series from EIA...", flush=True)
    saved = 0
    for i, series_id in enumerate(state_series):
        state_code = re.search(r"_S([A-Z]{2})_", series_id)
        state_code = state_code.group(1) if state_code else ("US" if "NUS" in series_id else "??")
        print(f"  [{i+1}/{len(state_series)}] {state_code}...", end=" ", flush=True)
        try:
            data = fetch_eia(api_key, series=series_id, start=start, end=end)
        except requests.HTTPError as e:
            if e.response.status_code == 404 or "not found" in str(e).lower():
                print("skip", flush=True)
                continue
            print(f"error: {e}", flush=True)
            continue
        if not data:
            print("no data", flush=True)
            continue
        rows = []
        for r in data:
            try:
                v = r.get("value")
                if v is not None:
                    rows.append({"period": r["period"], "value": float(v)})
            except (KeyError, ValueError, TypeError):
                continue
        if not rows:
            print("no rows", flush=True)
            continue
        df = pd.DataFrame(rows)
        path = EIA_DATA_DIR / f"{state_code}.parquet"
        _append_to_parquet(path, df, ["date"])
        saved += 1
        print(f"ok ({len(rows)} rows)", flush=True)
    print(f"Saved {saved} series to {EIA_DATA_DIR}/")


def _append_to_parquet(path: Path, new_df: pd.DataFrame, dedup_cols: list[str]) -> None:
    """Append new rows to existing Parquet, deduplicating."""
    new_df = new_df.rename(columns={"value": "regular", "period": "date"})
    new_df = new_df.drop(columns=["series_id"], errors="ignore")
    if path.exists():
        existing = pd.read_parquet(path)
        combined = pd.concat([existing, new_df], ignore_index=True)
        combined = combined.drop_duplicates(subset=dedup_cols, keep="last")
        combined = combined.sort_values("date").reset_index(drop=True)
    else:
        combined = new_df
    combined.to_parquet(path, index=False)


def main() -> None:
    parser = argparse.ArgumentParser(description="Fetch EIA gas price data via API")
    parser.add_argument("--list", action="store_true", help="List available series IDs")
    parser.add_argument("--national", action="store_true", help="Fetch national series only")
    parser.add_argument("--start", help="Start date (YYYY-MM-DD)")
    parser.add_argument("--end", help="End date (YYYY-MM-DD)")
    args = parser.parse_args()

    api_key = get_api_key()
    print("EIA API key loaded.", flush=True)

    if args.list:
        facets = list_series(api_key)
        if not facets:
            print("Could not fetch series list. Try fetching data directly.")
            return
        for f in facets:
            print(f["id"])
        return

    fetch_and_save(
        api_key,
        start=args.start,
        end=args.end,
        national_only=args.national,
    )


if __name__ == "__main__":
    main()