The BetterVoting JSON export — field-by-field¶
Level: reference · deep dive
One line: a BetterVoting export is JSON.stringify({Election, Ballots, Results}) of three raw in-memory objects — not a designed format — so it mixes casing, repeats every candidate four times, overloads one score field to mean score or rank, and is the reason issue #1420 is open.
This page documents what the export actually contains, so a reader can open any frozen *_bv_export.json in this repo and know what each key means. It is the JSON counterpart to How to read a BetterVoting results page (the visual report) and to BV's database schema (the storage model — the Election block below is essentially one electionDB row).
Everything here was checked against the 211 frozen exports committed in this repo (401 race results across seven voting methods), not against a single sample — where a key is inconsistent, the counts say so.
The three top-level keys¶
| Key | What it is | API endpoint that produces it |
|---|---|---|
Election |
The election's configuration: races, candidates, settings, ownership, state | GET /API/Election/{id} → .election |
Ballots |
Every cast ballot, anonymized | GET /API/Election/{id}/anonymizedBallots → .ballots |
Results |
BetterVoting's own tabulation, one entry per race | GET /API/ElectionResult/{id} → .results |
The UI's "Download JSON" button concatenates exactly these three. fetch_bv_export.py reproduces it from the three anonymous GETs — verified byte-identical on vqyqkr for Election and Results, and identical up to ballot order for Ballots.
Two variants appear in the frozen corpus:
Results: []plus a_note— written byfetch_bv_export.py --without-resultswhen BV's tabulator 500s on that election (the STV sole-survivor crashers). The gap is self-documenting rather than silent.election/precinctFilteredElection/voterAuth(lowercase, one file) — a rawGET /Electionresponse saved before the house format settled. Not the export shape; don't copy it.
Election¶
Present in all 210 exports that have the key. Values below are what the corpus actually contains.
| Field | Type | Notes |
|---|---|---|
election_id |
string | The 6-character bvid (yyvwrj). The whole repo names case files after it. |
title |
string | Permanent and public — the API cannot rename it. House rule: it carries the BV<n> test id. |
description |
string | null | Also permanent. null on UI-created elections; ours always ends in the lesson backlink. |
frontend_url |
string | Always "" in practice. Dead field. |
start_time / end_time |
ISO-8601 | null | null on 206 of 211 — an election with no window is simply open. |
owner_id |
uuid | Creator. Does not grant UI admin rights (see the API notes). |
audit_ids / admin_ids / credential_ids |
null | null in every frozen export. Setting admin_ids at mint is ignored by BV. |
state |
enum | open (188) · draft (12) · closed (10). Results tabulate on demand — an election need not be closed. |
races |
array | One object per contest. See below. |
settings |
object | See below. |
auth_key |
string | absent | The RS256 public PEM. Present on only 22 exports; the API requires it at create time. |
claim_key_hash |
sha-256 hex | Owner's claim key digest. |
is_public |
bool | null | null on 197 — meaning "not set", not "false". Listability is decided elsewhere. |
create_date |
ISO-8601 string | e.g. 2026-07-30T02:14:33.849Z. |
update_date |
epoch-ms string | e.g. "1785378669765". A different encoding of the same kind of value — one of the defects #1420 names. |
head |
bool | true on the current version. BV stores elections append-only; see database_schema. |
ballot_source |
enum | live_election throughout (vs an admin bulk upload). |
public_archive_id |
null | Unused in the corpus. |
Election.settings¶
Only the first six keys appear on every election; the rest are era-dependent, so read them with .get(), never by index.
| Field | Seen | Values |
|---|---|---|
voter_access |
210/210 | open |
voter_authentication |
210/210 | Object of flags — {"ip_address": false, "voter_id": false, …}, sometimes {} |
ballot_updates |
210/210 | false |
public_results |
210/210 | true |
random_candidate_order |
210/210 | false (198) / true (12) |
require_instruction_confirmation |
210/210 | true (204) / false (6) |
time_zone |
198/210 | America/Los_Angeles, America/New_York |
term_type |
25/210 | election |
contact_email |
25/210 | string, often "" |
draggable_ballot |
19/210 | false |
voter_access, voter_authentication and invitation are not independent — only six combinations are legal, and which one an election is in decides whether turnout, quorum and delivery reporting exist for it at all. There is no "demo election" flag; that property is derived from these three. See the six voter-authentication modes. Note the counts above: voter_access is open in 210/210 exports and the authentication flags are all-false, so every frozen export in this repo is an unauthenticated election — the open_open mode.
| max_rankings | 1/210 | Normally lives on the race, not here |
Election.races[]¶
| Field | Seen | Notes |
|---|---|---|
title |
419/419 | Permanent. The /vote page leads with it, which is why house rule puts BV<n> on every race title. |
race_id |
419/419 | "0" on single-race elections; a uuid on ones minted by our script. Ballots[].votes[].race_id joins to this. |
num_winners |
419/419 | Seats. |
voting_method |
419/419 | STAR · Approval · Plurality · IRV · STV · RankedRobin · STAR_PR (BV's own strings). |
candidates |
419/419 | [{candidate_id, candidate_name}]. Ballot order is this order. |
description |
410/419 | Per-race blurb. |
enable_write_in |
247/419 | Absent entirely on pre-flag-era races. |
max_rankings |
146/419 | Ranked methods only — the rank cap. |
Ballots¶
Flat and simple — three nested levels and nothing else:
{ "ballot_id": "b-kk2bwj7v",
"election_id": "yyvwrj",
"precinct": null, // null in all 419 frozen ballots
"votes": [ // one entry per RACE the voter voted in
{ "race_id": "0",
"scores": [ { "candidate_id": "c-pb2", "score": 4 } ] } ] }
score is overloaded — its meaning depends on the race's voting_method. This is the single biggest trap in the format, because nothing inside Ballots says which reading applies; you must join back to races[].voting_method.
| Method | What score means |
Values seen in the corpus |
|---|---|---|
| STAR, STAR_PR, Bloc STAR | A score, 0–5 | 0–5, plus null |
| Approval | Approved or not | 0 / 1 only |
| Plurality | Chosen or not | 0 / 1, plus null |
| IRV, STV, RankedRobin | A rank — 1 = top choice |
1–9 observed; 0 = unranked; plus null |
So a 3 means "three stars" on one race and "third choice" on the next race of the same election.
null vs 0 — the distinction that matters¶
A null score means the voter left that candidate blank; a 0 means they actively scored zero. BV's tabulators count both as zero, but the export preserves the difference — 518 null scores across the frozen corpus. That is the raw material for every abstention lesson in this repo, and 01_convert_json_yaml.py carries it across as the blank marker - rather than flattening it to 0.
The CSV download did flatten it (blank cell, ambiguous against a real 0), which is what the Raw/Official split in PR #1419 was for. See the abstain issues index for how this interacts with BV's open abstention tickets.
Results¶
One entry per race, in race order. Seven top-level fields are universal; four more are method-specific.
| Field | Seen | Notes |
|---|---|---|
votingMethod |
401/401 | camelCase — while Election and Ballots are snake_case. |
elected |
401/401 | The winner(s), as full candidate objects. |
tied / other |
401/401 | The rest, also as full candidate objects. |
roundResults |
401/401 | Per-round detail; see below. |
summaryData |
401/401 | The tally. |
tieBreakType |
401/401 | none (338) · random (40) · score (14) · head_to_head (6) · five_star (3) — which rung of the ladder actually fired. |
perm |
401/401 | Candidate ids in tiebreak priority order. See below. |
writeInDiagnostics |
97 | Write-in handling. |
exhaustedVoteCounts, nExhaustedViaOvervote, nExhaustedViaSkippedRank, nExhaustedViaDuplicateRank |
57 | IRV/STV only — exhausted-ballot accounting. |
logs |
13 | A race-level log array on a few results, separate from the per-round one. |
The candidate object, repeated four times¶
The same candidate object appears in elected, tied, other, summaryData.candidates, and again inside every roundResults[].winners / .runner_up. It carries:
| Field | Methods | Meaning |
|---|---|---|
id, name |
all | |
tieBreakOrder |
all | Integer, ascending = higher priority. |
votesPreferredOver |
all | Map {opponent_id: count} — including self-vs-self at 0. This is the O(n²) bloat #1420 names. |
winsAgainst |
all | Map {opponent_id: bool} — the pairwise wins, self-pair included. |
score |
STAR, Approval, Plurality, Score | The scoring-round total (or approvals / votes). |
fiveStarCount |
STAR only | Five-star counts — tiebreak rung 2. |
copelandScore |
RankedRobin only | Pairwise wins. |
hareScores |
IRV/STV only | Per-round vote counts. |
winsAgainst is genuinely useful despite the bloat: it encodes the full pairwise relation, which is how a Condorcet cycle shows up in a frozen file (bv2212_g3f7r2 records Alice▸Ben, Ben▸Carla, Carla▸Alice).
summaryData¶
candidates plus the ballot accounting, which is where the denominators come from:
nTallyVotes— ballots that counted in this racenAbstentions— ballots that skipped itnOutOfBoundsVotes— rejected valuesnOvervotes— Plurality onlyspentAboves,splitPoints,weight_on_splits,weightedScoresByRound— STAR_PR only (the reweighting trace)
roundResults[]¶
winners · runner_up · tied · tieBreakType · logs, plus eliminated on IRV/STV and exhaustedVoteCount / isStartOfSearch on IRV.
Round counts are method-shaped: STAR is usually one round (147 of 178), IRV usually two to four, and STAR_PR emits zero rounds — its trace lives in summaryData instead. One IRV race in the corpus has 60 rounds.
logs — two incompatible shapes¶
| Shape | Methods | Example |
|---|---|---|
| i18n dict | STAR, IRV, STV | {"key": "tabulation_logs.star.runoff_win", "winner": "A", "winner_votes": 1, …} |
| Plain English string | Plurality, RankedRobin, Approval | "Sushi wins the round after a random tiebreaker" |
Anything parsing logs must handle both. The dict form is the good one — it names the rung (runoff_tiebreak, five_star_tied, random_second) in a machine-readable key. Surfacing these in BV's own report is tracked in #1432.
perm — the pre-drawn lot¶
perm lists candidate ids in tiebreak priority order, highest first, and summaryData.candidates[].tieBreakOrder is the same information spread across candidates. Despite BV labelling rung 3 "random", it is a seeded shuffle — seed = (rawVoteCount + hash(raceId)) >>> 0 — so the export records the complete order and a re-tally reproduces it. Replay it with bv_replay_tiebreak.py.
The full mapping into a YAML lot_numbers: line is its own page: Tie-Breaking in BetterVoting JSON, with the ladder itself in STAR Tie-Breaking — The Full Chain.
The other direction — what a mint can populate¶
Sending a case to BetterVoting goes through create_bv_test_election.py, which clones a template election and overwrites part of it. Only three groups of fields are ours:
| Group | Fields | Set by |
|---|---|---|
| We set | title, description; per race title, voting_method, num_winners, max_rankings, enable_write_in, candidates[] |
The spec in bv_election_specs.py |
| Inherited from the template | the whole settings block — voter_access, voter_authentication, ballot_updates, public_results, random_candidate_order, require_instruction_confirmation, time_zone, term_type, contact_email, draggable_ballot, start_time, end_time |
Whichever election BV_TEMPLATE_ID points at (default pet) |
| Server-assigned | election_id, create_date, update_date, head, state, claim_key_hash, ballot_source, and every Results field |
BetterVoting |
Three consequences worth knowing before minting:
settingsis not spec-controllable. To change voter access or the candidate-order flag, pointBV_TEMPLATE_IDat an election that already has the setting you want — there is no per-spec override.admin_idsis accepted and ignored. API-minted elections are public, listable and exportable, but never UI-administrable. Setting it was tested; it does nothing.titleanddescriptionare permanent. No API path edits them. This is why the URL in a description backlink getscurl-checked for a 200 before the mint.
Ballots are cast separately (one POST per ballot), which populates Ballots[].votes[].scores[]. Results is never sent — BV computes it, and that is exactly what makes it a genuine cross-check of the LH count rather than an echo of it.
The end-to-end workflow lives in the bettervoting skill and the tool guide.
Known defects, and the open issue¶
Adam filed #1420 — "Download JSON export leaks the tabulator's internal object shape" (open since 2026-07-05), naming four defects, all of which the corpus above confirms:
- Every candidate's O(n²)
votesPreferredOver/winsAgainstmaps, keyed by uuid, self-pairs included, duplicated acrosselected/tied/other/summaryData/roundResults. Resultsin camelCase whileElectionandBallotsare snake_case.- Inconsistent timestamps — ISO-8601
create_datebeside epoch-ms-stringupdate_date. - No
format_version, so a consumer cannot tell which era of export it has.
PR #1419 — "Clean up the ballot-data export (JSON v2 + CSV Raw/Official)" — implemented a fix in packages/shared/src/utils/exportFormat.ts: candidates listed once, a deduped pairwise matrix keyed by name, {id,name} refs, snake_case throughout, ISO timestamps, format_version: 2, score: null preserved, and the CSV split into Official Count / Raw Audit. It measured 69–74% of legacy size on real exports from this repo.
The PR was split, not rejected. A maintainer extracted its CSV bug fixes into PR #1428 — on a branch named masiarek/csv-escaping-fix, with the commit keeping Adam's authorship — and merged that on 2026-07-16. The format redesign was deferred with "the rest needs some contemplation", and #1419 was closed on 2026-07-15. So the export shipping today has correct CSV escaping and download behaviour, but the JSON is still v1 — which is what this page documents, and why #1420 stays open.
The format half was re-proposed as PR #1492 on 2026-08-07, this time additive — +391/−0, no deletions at all. The existing "Download JSON" is left byte-identical and the compact shape is a second menu item writing a .v2.json file, so the question in front of the maintainers is no longer "should we migrate the export format" but "may people opt into a cleaner one." Verified across 197 distinct real exports (0 failures on score-multiset, null preservation, elected set, and counts); median size 76% of legacy, best 43% on a 9-candidate field. Until it lands, this page describes what BetterVoting ships.
The contribution workflow and what was learned from it: contributing to BetterVoting.
Related open tickets: #1160 (dual raw/processed export), #1432 (surface tie-break explanations).
What this repo reads out of an export¶
| Consumer | Reads |
|---|---|
01_convert_json_yaml.py |
Election.races[] → one YAML per race; Ballots → the score block (null → -); perm or tieBreakOrder → lot_numbers:; elected → the answer key |
bv_replay_tiebreak.py |
Re-derives the seeded shuffle and checks it against perm |
build_multirace_index.py |
Election.races[] + Ballots + Results — the source of truth for the multirace index |
| Case pages | elected and tieBreakType, quoted as BV's independent verdict beside the LH count |
Worked examples to open alongside this page: bv2105r2_w3vvff_ice_cream_recheck_bv_export.json (Bloc STAR with null scores) and bv2261_y2fbpc_tiebreak_recorded_bv_export.json (a recorded tiebreak order).
See also¶
- How to read a BetterVoting results page — the same data as the visual report
- BV database schema — where
ElectionandBallotsare stored - BetterVoting and the LH engine — one election, two reports
- BV API election creation notes — what the API can and cannot do
- Tie-Breaking in BetterVoting JSON —
perm→lot_numbers:in full