Getting the files
Sign in and open Bulk Data Download. Every plan can see the page: every file for every period, with itssha256, byte size and row count, a
per-period completeness label so you can tell which dates are settled before you load one,
and a summary of what each file contains. Downloading the files is included on the
Intelligence, Enterprise and Academic plans.
If you need this automated rather than downloaded by hand, tell us — a programmatic manifest
exists and we will publish it as a supported contract once every period exports cleanly.
Files
Reading the two formats
Parquet carries real types: a null is a null, an array is aLIST, and a timestamp is a
TIMESTAMP. Prefer it if your tooling can read it.
CSV has neither nulls nor arrays, so two conventions apply and they are the only differences
between the formats:
- Array columns are JSON arrays, written as a single quoted field — parse them with your JSON reader, not by splitting on a delimiter. A title containing a comma would break the row otherwise, and titles contain commas.
- An empty field is a null. CSV cannot distinguish “no value” from “the empty string”, and in this data the distinction matters: see the note on metrics below.
Coverage
Each file records the settled dates it covers against the calendar days in its period. A month is only complete when those two agree; the manifest publishes both, so a partial month is visible rather than silently short.Nulls are information, not gaps
A null metric does not mean zero. It means the column does not apply to that row: the conflict family carries nomagnitude, systemic_importance, propagation_potential or
market_sensitivity, the CAMEO+ family carries no fatalities, and a story with no coded
events has no fatality count at all. Coalescing these to 0 on load will invent measurements
that were never taken, and any average you compute afterwards will be wrong in a direction you
cannot recover.
The same applies to three-valued booleans such as civilian_targeting: true where a coder
judged civilians were targeted, false where a coder judged they were not, and null where no
coder evaluated the question at all.
Event counts are as of the export, not as of your query
linked_event_count, fatalities, civilian_targeting_event_count,
max_linked_event_significance, linked_event_ids and the three has_* flags are counted
over all of a story’s events at the moment the file was cut.
The API does something different, deliberately: it recomputes them under your filters, so
?category=Protests returns each story’s PROTEST event count rather than its total. It also
excludes events that have since been merged away. Two consequences worth knowing before you
reconcile a file against a response:
- A story with no events has
fatalitiesas null here, where the API serves0. Null is the honest value: there were no events to count deaths from. Do not coalesce it to zero, or any average you compute afterwards is wrong in a direction you cannot recover. linked_event_idscan name an event the API no longer returns, if an adjudicator merged it into another after this file was cut. Join onevent_uidagainst the events file for the same period and treat a miss as a merge, not as corruption.
as_of on every row tells you what that row knew and when.
A story is identified by its date AND its id
story_id is unique within a day, not across days. A story that continues into a later
day carries the same id under that day’s date — measured over March 2026, 544 ids appear on
more than one date, one of them on five. That is the system working: a story is a daily
cluster of articles, and the same cluster continuing is the same id.
So when you concatenate monthly files, join and de-duplicate on (story_date, story_id).
De-duplicating on story_id alone silently drops days of a continuing story, and the row you
keep is whichever one your tool happened to see first.
There is no country display name, only the code
country_iso3 is the country the story is about: its own attribution when it has one, and
otherwise the country of its events. That is the same precedence ?country= and
group_by=country use, so a filter you write against this column returns what the API would.
The warehouse also stores an event-derived display name, and it is deliberately not
published: for a story whose attribution is not its event location the two name different
countries, and whichever column you reached for first would decide whether your numbers
reconcile with ours. story_country_iso3s carries every country the story is about, most
central first — ?country= matches any of them while group_by=country counts the first.
Stories
One row per story, ordered bystory_date, story_id. Every column below is a field
/api/v2/stories returns — the file and the endpoint read the same table, so a
row in the file and the same row from the API agree field for field.
37 columns, 18 of them nullable.
Summary statistics
Every published file carries a summary of its own contents, computed by the run that cut it and shown on Bulk Data Download. It is there so you can see what is in a period before downloading it. Value counts — the most frequent values, with how many distinct ones exist and how many rows carry none. An absent value is reported separately and never counted as a category.
Distributions — count of rows carrying the metric, minimum, the 10th, 25th, 50th, 75th
and 90th percentiles, maximum and mean. Quantiles are exact and inclusive-interpolated, the
same convention pandas and Excel use, so you can reproduce them from the file you downloaded.
Percentiles are computed over the rows where the metric is PRESENT, so a metric that applies
to part of the file describes that part — it is never padded with zeroes to fill the rest.
How the export differs from the warehouse
Most columns ship exactly as stored. Two conventions apply to all of them, and a handful of columns are computed at export time. An absent value is null, never an empty string. Wherever a value is missing the file carries a true null in Parquet and an empty field in CSV, so anIS NULL check finds every
absent value. Column names are the API’s names, so a row in the file and the same row
from /api/v2/stories agree field for field.

