EIA-860 and EIA-923 Explained: Plants, Generators and What They Generate
On this page
Problem statement
Almost every dataset about US power plants β maps, emissions estimates, capacity factors, the generation mix β is built from two EIA surveys. EIA-860 describes the fleet: plants, generators, their technology and capacity. EIA-923 records what the fleet did: monthly generation and fuel use. They share a plant code but not a structure β one is organised by generator, the other by plant, prime mover and fuel β and they disagree about which plants exist. This page lays out the two forms side by side on the 2025 data, with the keys that join them and the places where they do not match.
Quick answer
EIA-860 is the inventory (what exists, one row per generator); EIA-923 is the activity (what it generated, one row per plant, prime mover and fuel, with twelve monthly columns). Join them on the plant code, at plant level; there is no generator-level generation in the main EIA-923 table.
g = read_923()
print(f"EIA-860 2025: {len(plants):,} plants, {len(gen):,} operable generators")
print(f"EIA-923 2025: {len(g):,} rows, {g['Plant Id'].nunique():,} plant IDs, {g['Net Generation (Megawatthours)'].sum() / 1e6:,.1f} TWh net generation")
both = set(plants["Plant Code"].astype(int)) & set(g["Plant Id"])
print(f"plant IDs in both: {len(both):,}")
EIA-860 2025: 17,349 plants, 27,918 operable generators
EIA-923 2025: 18,889 rows, 14,045 plant IDs, 4,425.0 TWh net generation
plant IDs in both: 14,007
In 2025 EIA-860 lists 17,349 plants and 27,918 operable generators; EIA-923 has 18,889 rows covering 14,045 plant IDs and 4,425.0 TWh of net generation. 14,007 plant IDs appear in both.
The keys and the codes
1. Utility, plant, generator
print(f"utilities: {gen['Utility ID'].nunique():,}; plants: {gen['Plant Code'].nunique():,}; generators: {len(gen):,}")
key = gen["Plant Code"].astype(int).astype(str) + "-" + gen["Generator ID"].astype(str)
print(f"generator IDs repeated across plants: {gen['Generator ID'].duplicated().sum():,}; plant + generator unique: {key.is_unique}")
utilities: 5,852; plants: 14,327; generators: 27,918
generator IDs repeated across plants: 17,027; plant + generator unique: True
Operable generators belong to 14,327 plants run by 5,852 utilities (EIA's word for any operator, including independent producers). A generator ID such as 1 or GEN1 repeats across plants β 17,027 times β so a generator is identified by plant code and generator ID together, which is unique.
2. Technology, prime mover, energy source
t = gen.groupby(["Technology", "Prime Mover", "Energy Source 1"]).size().sort_values(ascending=False)
print(t.head(8).to_string())
print(f"distinct technology / prime mover / fuel combinations: {len(t)}")
Technology Prime Mover Energy Source 1
Solar Photovoltaic PV SUN 7999
Conventional Hydroelectric HY WAT 3981
Petroleum Liquids IC DFO 3294
Natural Gas Fired Combustion Turbine GT NG 2240
Natural Gas Internal Combustion Engine IC NG 1849
Onshore Wind Turbine WT WND 1570
Natural Gas Fired Combined Cycle CT NG 1213
Landfill Gas IC LFG 1123
distinct technology / prime mover / fuel combinations: 78
Three columns describe what a generator is. Technology is EIA's summary category, Prime Mover the machine (PV for photovoltaic, GT for gas turbine, CT for the combustion-turbine part of a combined cycle, HY for hydro) and Energy Source 1 the main fuel. The most common combination is solar PV (7,999 generators); there are 78 combinations in all. EIA-923 reports by prime mover and fuel, so those two codes β not Technology β are what line the two files up.
3. Generation is reported by prime mover and fuel
rows = g.groupby("Plant Id").size()
print(f"EIA-923 rows per plant: median {rows.median():.0f}, max {rows.max()}")
print("respondent frequency:", g["Respondent Frequency"].value_counts().to_dict())
EIA-923 rows per plant: median 1, max 352
respondent frequency: {'AM': 9538, 'M': 6036, 'A': 2963}
Most plants have a single EIA-923 row; a plant burning gas and oil in several kinds of unit has one row for each combination. The largest count, 352, belongs to plant ID 99999, which is not a plant at all but EIA's state-level adjustments (EIA-923 plant 99999). The Respondent Frequency column marks plants that report monthly (M), once a year (A) and a third group, AM.
4. Who reports monthly
fr = g.groupby("Respondent Frequency")["Net Generation (Megawatthours)"].sum()
print("share of net generation by respondent frequency:", (fr / fr.sum()).round(3).to_dict())
share of net generation by respondent frequency: {'A': 0.021, 'AM': 0.096, 'M': 0.883}
Monthly reporters produce 88.3% of net generation; annual reporters 2.1%. EIA estimates the monthly values of annual reporters, so their monthly columns are modelled, not measured β a detail worth remembering in any month-by-month analysis.
Where the two files disagree
The data used on this page
The US Energy Information Administration's EIA-860 generator inventory for 2025 and EIA-923 generation and fuel data for 2025 (final), as released in September 2026 (saved copies).
import io
import zipfile
from pathlib import Path
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
# EIA-860 (2025, released September 2026), EIA-923 (2025 final) and EIA-860M (August 2026), saved on 15 October 2026.
# Live: https://www.eia.gov/electricity/data/eia860/xls/eia8602025.zip, .../eia923/xls/f923_2025.zip and
# .../eia860m/xls/august_generator2026.xlsx; the annual files move to archive/xls/ when the next year's data appear.
FILES = {"eia860": "eia-860-2025-2026-10-15.zip", "eia923": "eia-923-2025-final-2026-10-15.zip",
"eia860m": "eia-860m-august-2026-2026-10-15.xlsx"}
for name in FILES.values():
if not Path(f"data/{name}").exists():
Path(f"data/{name}").write_bytes(requests.get(f"https://data.spatialworkflow.io/{name}", timeout=600).content)
def read_860(member, sheet=0):
"""One sheet of an EIA-860 workbook; the first row is a title, the last row of some sheets a note."""
with zipfile.ZipFile(f"data/{FILES['eia860']}") as z:
d = pd.read_excel(io.BytesIO(z.read(member)), sheet_name=sheet, skiprows=1)
return d[pd.to_numeric(d["Plant Code"], errors="coerce").notna()]
def read_923(sheet="Page 1 Generation and Fuel Data"):
with zipfile.ZipFile(f"data/{FILES['eia923']}") as z:
d = pd.read_excel(io.BytesIO(z.read("EIA923_Schedules_2_3_4_5_M_12_2025_Final.xlsx")), sheet_name=sheet, skiprows=5)
d.columns = [c.replace("\n", " ") for c in d.columns]
return d
GROUP = {"Conventional Steam Coal": "coal", "Coal Integrated Gasification Combined Cycle": "coal",
"Natural Gas Fired Combined Cycle": "gas", "Natural Gas Fired Combustion Turbine": "gas",
"Natural Gas Steam Turbine": "gas", "Natural Gas Internal Combustion Engine": "gas",
"Natural Gas with Compressed Air Storage": "gas", "Other Natural Gas": "gas", "Nuclear": "nuclear",
"Onshore Wind Turbine": "wind", "Offshore Wind Turbine": "wind", "Solar Photovoltaic": "solar",
"Solar Thermal with or without Energy Storage": "solar", "Conventional Hydroelectric": "hydro",
"Batteries": "storage", "Hydroelectric Pumped Storage": "storage", "Flywheels": "storage",
"Petroleum Liquids": "oil", "Petroleum Coke": "oil", "Wood/Wood Waste Biomass": "biomass",
"Landfill Gas": "biomass", "Municipal Solid Waste": "biomass", "Other Waste Biomass": "biomass",
"Geothermal": "geothermal"}
gen = read_860("3_1_Generator_Y2025.xlsx", "Operable")
gen["MW"] = pd.to_numeric(gen["Nameplate Capacity (MW)"], errors="coerce")
gen["group"] = gen.Technology.map(GROUP).fillna("other")
plants = read_860("2___Plant_Y2025.xlsx")
print(f"{len(plants):,} plants, {len(gen):,} operable generators, {gen.MW.sum() / 1000:,.1f} GW nameplate")
17,349 plants, 27,918 operable generators, 1,376.7 GW nameplate
Example 1: plants in one file and not the other
only860 = set(plants["Plant Code"].astype(int)) - set(g["Plant Id"])
only923 = set(g["Plant Id"]) - set(plants["Plant Code"].astype(int))
print(f"in EIA-860 only: {len(only860):,}; in EIA-923 only: {len(only923):,} (including {99999 in only923 and 'the state-level ID 99999'})")
new = gen[gen["Plant Code"].isin(only860)]
print(f"EIA-860-only plants that started in 2025 or later: {(pd.to_numeric(new['Operating Year'], errors='coerce') >= 2025).groupby(new['Plant Code']).any().mean():.1%}")
in EIA-860 only: 3,342; in EIA-923 only: 38 (including the state-level ID 99999)
EIA-860-only plants that started in 2025 or later: 8.6%
3,342 plant codes are in EIA-860 only: the plant sheet lists proposed and retired plants as well as operating ones, and only 8.6% of these had a generator starting in 2025 or later. 38 IDs are in EIA-923 only, including 99999. Joining the two with an inner join silently drops both groups.
Example 2: the sectors
sec = g.groupby("Sector Name")["Net Generation (Megawatthours)"].sum().sort_values(ascending=False) / 1e6
print("net generation by sector, TWh:", sec.round(1).to_dict())
net generation by sector, TWh: {'Electric Utility': 2304.5, 'NAICS-22 Non-Cogen': 1840.2, 'Industrial NAICS Cogen': 131.2, 'NAICS-22 Cogen': 127.3, 'Commercial NAICS Cogen': 8.3, 'Commercial NAICS Non-Cogen': 7.1, 'Industrial NAICS Non-Cogen': 6.6}
Electric utilities generated 2,304.5 TWh and independent power producers ("NAICS-22 Non-Cogen") 1,840.2 TWh; combined heat and power plants at factories and campuses add the rest. The sector is the owner's business, not the technology.
Two surveys, one fleet
EIA runs the two surveys for different purposes. EIA-860 tracks the physical system so that planners can see what capacity exists and what is coming and going; it changes when a generator is built, uprated, mothballed or retired. EIA-923 tracks operation and fuel, monthly for large plants, so that EIA can publish electricity statistics and fuel use; it changes every month. A generator can be in EIA-860 for years before it produces a megawatt-hour, and a plant's EIA-923 rows can mix generators of different technologies under one prime-mover code.
That is why plant-level joins work and generator-level ones do not, and why analyses such as capacity factors restrict themselves to plants whose generators are all of one kind. EIA also publishes generator-level generation (Page 4 of the EIA-923 workbook), but only for plants that can report it.
Things that break joins
- Column names differ.
Plant Codein EIA-860,Plant Idin EIA-923;StateagainstPlant State. - Types differ. Plant codes arrive as integers in one workbook and objects in another; cast before merging.
- Release timing. The annual EIA-860 and the final EIA-923 for a year appear months apart, with early-release versions in between; use the same release generation for both.
- Puerto Rico is on separate sheets in EIA-923 and flagged as possibly incomplete.
Internal links
- How to map US power plants from EIA-860 β the plant file on a map
- EIA-923 monthly values are dots β the monthly columns
- EIA-923 plant 99999 β generation with no plant
FAQ
What is the difference between EIA-860 and EIA-923?
EIA-860 is the annual inventory of generators and plants (capacity, technology, location); EIA-923 reports generation and fuel use, monthly for most of it.
How do I join EIA-860 and EIA-923?
On the plant code (Plant Code = Plant Id), at plant level. In 2025, 14,007 plant IDs appear in both.