Skip to content

Field History From Backup CSVs

Salesforce Field History only tracks what you enable. We already had daily full-object backups in Own — this local toolkit turns them into queryable day-by-day field history.

Matt Dennis

For a Closed Won opportunity, when did each required field first get a value — and what stage was the record in that day? Salesforce Field History often can’t answer. You only get history for fields you enable, retention is finite, and the UI is a terrible place to ask the same question across fifty records and forty fields.


We already paid for something close: Own (OwnData) takes daily full-object backups of production for disaster recovery. Those backups are complete CSVs behind a recover UI — fine for restore, not a queryable timeline.


So I built a local toolkit that downloads those day-by-day exports, stores them as Parquet, diffs consecutive days into DuckDB, and answers the lifecycle questions Field History won’t.


The gap Field History leaves

Field History Tracking is opt-in per field. Most orgs leave the majority of custom fields untracked because enabling everything is expensive and noisy. When someone asks whether a stage-gate field was filled in Discovery or only after Closed Won, you either get lucky that the field was tracked, or you guess from CreatedDate / LastModifiedDate and hope.


Purpose-built snapshot objects help for the fields you designed them around. They don’t give you every column on Opportunity for free. The backup product already has every column — just not in a form you can query.


Own → Parquet → DuckDB

The pipeline is deliberately boring:


Own daily backups
  → sync (download missing days)
  → inbox CSVs
  → Parquet partitions (as_of=YYYY-MM-DD)
  → DuckDB field_changes
  → query / report

Auth is cookie-based. Sign into Own in a browser session, export the cookies once, never put passwords in git. The preferred command is one sync that downloads anything missing and ingests anything not yet in Parquet:


owndata sync --days 60 --object Opportunities
owndata status

Each day becomes a partition. Ingest diffs that day against the previous as_of for a watchlist of fields (default: every column except system noise like LastViewedDate). Rows land in field_changes with opportunity_id, field, old/new values, and the snapshot date. Views over the Parquet tree give you opportunity_snapshots and opportunity_latest for ad hoc SQL.


SELECT as_of, field, old_value, new_value
FROM field_changes
WHERE object_name = 'opportunities'
  AND opportunity_id = '006XXXXXXXXXXXXXXX'
  AND field IN ('StageName', 'CloseDate')
ORDER BY as_of, field;

Accounts and other objects use the same path (--object Accounts). Default is Opportunities — that’s where the stage-gate questions live.


What it unlocked

The report command walks daily snapshots for a list of Opportunity Ids and, for each watched field, finds the first day the value became non-empty — plus the StageName on that day. Resolution is the backup cadence: “sometime before this morning’s export,” not an exact edit timestamp. That’s enough to audit whether stage requirements were filled when the process claims they were, or only backfilled at the end.


Coding agents can run the toolkit cold: setup, cookie refresh when auth fails, sync, status, report. Cookies, CSVs, Parquet, and DuckDB stay local and gitignored. Nothing deploys to Salesforce.


What it is not

Not live Salesforce. Not a substitute for a designed snapshot object. Not sub-day precision. If you need “who changed Amount at 2:14pm,” enable Field History on Amount — or accept that daily diffs won’t tell you.


If you already buy full-object backup CSVs, day-partitioned Parquet plus consecutive diffs is the cheapest full-field history you can get without turning Field History on for everything.