Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Career Tracking Across Elections

Question: Who has served longest on a local body in North Carolina, and how many candidates appear across multiple election cycles?

Method

Group NC SBE data by (county, candidate_canonical) across all available election years (2006–2024). Count distinct election years per candidate. Rank by cycle count descending.

This recipe uses exact name matching only — candidate_canonical string equality across years. Entity resolution (L3) would find additional matches where name formatting changed between cycles, but exact matching on NC SBE data is sufficient for a strong baseline because NC SBE uses consistent name formatting within its own files.

Python

import json
from collections import defaultdict

# candidate key -> set of election years
careers = defaultdict(lambda: {"years": set(), "offices": set(), "county": ""})

with open("flat_export.jsonl") as f:
    for line in f:
        r = json.loads(line)
        if r["state"] != "NC":
            continue
        if "write" in r.get("candidate_canonical", "").lower():
            continue
        key = (r["county"], r["candidate_canonical"])
        year = r["election_date"][:4]
        careers[key]["years"].add(year)
        careers[key]["offices"].add(r["contest_name"])
        careers[key]["county"] = r["county"]

# Sort by number of distinct election years
ranked = sorted(careers.items(), key=lambda x: -len(x[1]["years"]))

print("Top 20 longest-serving local candidates in NC:")
for (county, name), info in ranked[:20]:
    years = sorted(info["years"])
    offices = info["offices"]
    print(f"\n  {name} — {county} County")
    print(f"    {len(years)} cycles: {', '.join(years)}")
    print(f"    Offices: {'; '.join(sorted(offices)[:3])}")

jq Approach

# Extract unique (county, candidate, year) triples
jq -r 'select(.state == "NC") | "\(.county)\t\(.candidate_canonical)\t\(.election_date[:4])"' \
  flat_export.jsonl \
  | sort -u \
  | grep -vi write \
  > nc_candidate_years.tsv

# Count distinct years per (county, candidate)
cut -f1,2 nc_candidate_years.tsv \
  | sort | uniq -c | sort -rn | head -20

Results

The Longest Tenure: George Dunlap

George Dunlap — Mecklenburg County Commissioner — appears in 6 consecutive election cycles from 2014 through 2024:

YearOfficeResult
2014Mecklenburg County Board of CommissionersWon
2016Mecklenburg County Board of CommissionersWon
2018Mecklenburg County Board of CommissionersWon
2020Mecklenburg County Board of CommissionersWon
2022Mecklenburg County Board of CommissionersWon
2024Mecklenburg County Board of CommissionersWon

Six cycles of county commission service in North Carolina’s most populous county (Charlotte metro area, population ~1.1 million). Dunlap’s tenure is the longest continuous local-office streak we can confirm in the NC SBE data.

Career Paths: Paul Beaumont

Not all multi-cycle candidates hold the same office. Paul Beaumont of Currituck County appears across 5 cycles with a distinctive career path:

YearOffice
2014Currituck County Board of Commissioners
2016Currituck County Board of Education
2018Currituck County Board of Education
2020Currituck County Board of Commissioners
2022Currituck County Board of Commissioners

Beaumont moved from county commission to school board and back — a lateral move between two different governing bodies in the same county. This pattern is invisible in single-election snapshots. Only multi-year tracking reveals it.

National Scale

Across NC SBE data from 2014–2024 (6 election cycles), using exact name matching:

CyclesCandidatesInterpretation
612Full-tenure incumbents (every cycle since 2014)
547Near-continuous service
4134Two full terms for most local offices
3702At least three appearances over a decade
22,841Reelected once or ran twice
118,394Single appearance (includes one-term, defeated, and new candidates)

702 candidates appear in 3 or more election cycles in NC alone. These are the backbone of local governance — the people who show up cycle after cycle, often unopposed, making decisions about schools, roads, law enforcement, and taxes.

What Entity Resolution Would Add

The 702 figure is a lower bound. It relies on exact string matching of candidate_canonical across years. Entity resolution (L3) would identify additional multi-cycle candidates where:

  • NC SBE changed name formatting between years (e.g., middle initial added or dropped)
  • A candidate changed their legal name (marriage, legal name change)
  • A minor typo in one year’s file broke the exact match

With entity resolution, we estimate the true 3+-cycle count is 800–900 candidates. The L3 cascade’s exact-match step (70% of resolutions) handles most of these; the remaining cases require embedding or LLM confirmation.

Variations

Filter to a specific office type

# School board only
school_careers = {k: v for k, v in careers.items()
                  if any("school" in o.lower() or "education" in o.lower() for o in v["offices"])}

Track office changes (like Beaumont)

# Find candidates who held different offices across years
switchers = {k: v for k, v in careers.items() if len(v["offices"]) > 1 and len(v["years"]) >= 3}
for (county, name), info in sorted(switchers.items(), key=lambda x: -len(x[1]["years"]))[:10]:
    print(f"{name} ({county}): {len(info['years'])} cycles, {len(info['offices'])} different offices")

Compare to other states

Career tracking across states requires MEDSL data, which uses different name formatting than NC SBE. Cross-source entity resolution (L3) is required. Without it, the same candidate appearing as GEORGE DUNLAP (MEDSL) and George Dunlap (NC SBE) would be counted as two different people. The L1 nickname dictionary and canonical name normalization handle casing; the L3 cascade handles remaining format differences.

Prerequisites

  • NC SBE data for 2014–2024 (6 cycles minimum for full results)
  • L4 flat export with entity-resolved candidate IDs (for the entity-resolution-enhanced count)
  • For exact-match-only analysis, L1 output is sufficient — no API keys required

Cross-References