Skip to main content
Bulk downloads hand you the same coded events 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.

Backtesting: what you can and cannot claim as-of

These files support a real point-in-time filter, and they do not support a full vintage. The difference decides whether a backtest is sound, so it is worth being exact.

The event SET is point-in-time. Use coded_at.

coded_at is the moment our coder produced the event, recorded per row and populated on 100% of rows. Every event is derived from articles published before it, and those articles are cited in source_urls, so the claim is checkable rather than asserted. That means WHERE coded_at <= T gives you the set of events that existed in our database at time T. It is a filter on a recorded fact, not a reconstruction. It is the same lever the API exposes as observed_start / observed_end. Measured over July 2026 (42,603 events): 79.4% were coded within a day of the event date, 98.6% within two days, 99.2% within seven. Median lag 16 hours, 90th percentile 33. Only 0.61% of July events were coded after July ended. So do not filter a backtest on event_date alone. Doing so hands your strategy events it could not have seen — about 1.4% of any given month arrived more than two days late, and the tail runs to weeks.

The VALUES in a row are not vintaged.

The row carries what we know now, not what we knew at coded_at. A later article can sharpen the location, the entity resolver can attach a name it could not resolve at the time, an adjudicator can merge the event with another. The settle overwrites in place; there is no per-field history. So coded_at <= T gives you the right ROWS with today’s VALUES. For most event-driven work that is fine — what happened, where, to whom and when we knew it are all stable. It matters if your signal keys on a revisable field: the metric scores, entity_names, or incident_uid, which is assigned by an adjudicator that runs after coding. One consequence worth designing for: event_uid is a stable hash of the cluster, but event_date is the coder’s judgement and is re-derived on a re-code. An event whose date moves therefore moves BETWEEN monthly files rather than changing position within one. Match on event_uid across files, not on event_uid within a month.

Events

One row per event, ordered by event_date, event_uid. Every column below is a field /api/v2/events 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. 41 columns, 34 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/events agree field for field.