Skip to main content
Bulk downloads hand you the same coded stories the API serves, as files instead of pages of JSON.

Getting the files

Sign in and open Bulk Data Download. Every plan can see the page: every file for every period, with its sha256, 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 a LIST, 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 no magnitude, 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 fatalities as null here, where the API serves 0. 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_ids can name an event the API no longer returns, if an adjudicator merged it into another after this file was cut. Join on event_uid against 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 by story_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 an IS 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.