A Data Versioning System keeps the complete history of changes to your records: who changed what, when, and how to recover or audit older versions. This is critical for audit, debugging, compliance (GDPR/financial), and features such as “time travel”, record revert, and change comparisons.
This article gives a practical, production-ready design with multiple implementation options (built-in temporal tables, manual history tables with triggers, and application-level capture), ER diagrams, flowcharts, SQL scripts, ASP.NET Core patterns, and best practices.
Goals
Capture every INSERT / UPDATE / DELETE for important tables.
Keep metadata: who, when, reason (optional), transaction id.
Efficient storage and query patterns.
Provide APIs to read history, diff versions and revert a record.
Retention policy and archive strategies.
Support both simple and complex schemas.
High-level architecture (Visio-style)
+-----------------+ writes/reads +-------------------+
| Application | <------------------> | Primary DB |
| (ASP.NET Core) | | (SQL Server) |
+-----------------+ +-------------------+
| |
| v
| +-----------------------+
| | Versioning Store |
| | - Temporal Tables OR |
| | - History Tables + |
| | Triggers |
| +-----------------------+
| |
v v
+-----------------+ +-------------------+
| Audit / Admin | | Archive / Cold |
| UI (history / | | storage (S3/Blob) |
| compare / revert)| +-------------------+
+-----------------+
Flowchart (change -> capture -> query -> revert)
User updates record
↓
Application executes SQL
↓
Versioning mechanism captures change
(temporal OR trigger OR app-level)
↓
History stored with metadata (user, ts, txid)
↓
Admin queries history / diffs
↓
Admin reverts (optional) -> write new record (audit captured)
ER diagram (core objects)
+-------------------+ +------------------------+
| MyEntity | 1 1| MyEntity_History |
+-------------------+ <------>+-----------------------+
| Id (PK) | | HistoryId (PK) |
| Name | | EntityId (FK) |
| Value | | ValidFrom |
| ... | | ValidTo |
+-------------------+ | ChangedBy |
| ChangeType (I/U/D) |
| PayloadJson (full row)|
| ChangeReason |
| TxId |
+-----------------------+
Two main approaches (summary)
System-versioned temporal tables (SQL Server feature, simple, performant, automatic).
Pros: automatic row-versioning, built-in time travel query, efficient.
Cons: less flexible metadata (who/why), harder to store JSON diffs, needs SQL Server 2016+.
Manual history tables + triggers / stored procedures.
Pros: full control (store user, reason, JSON diffs, tx ids), easier to extend.
Cons: more code, need to manage triggers and retention.
Additionally, application-level capture (EF Core interceptors, change tracker) can supplement to include user/context info and business logic.
Option A — System-versioned temporal table (recommended when available)
1) Create temporal table
CREATE TABLE dbo.Customer
(
CustomerId UNIQUEIDENTIFIER NOT NULL PRIMARY KEY DEFAULT NEWID(),
Name NVARCHAR(200) NOT NULL,
Email NVARCHAR(200),
Balance DECIMAL(18,2) DEFAULT 0,
SysStartTime DATETIME2 GENERATED ALWAYS AS ROW START NOT NULL,
SysEndTime DATETIME2 GENERATED ALWAYS AS ROW END NOT NULL,
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime)
)
WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.CustomerHistory));
SQL Server creates dbo.CustomerHistory automatically with the row versions. But CustomerHistory does not include ChangedBy or ChangeReason.
2) Add audit metadata (who/why)
You can store user info in a separate audit table or extend approach by writing triggers to insert augmented history or use an application-level write to an audit table.
Example: keep CustomerHistoryMeta where you store (HistoryRowPK, ChangedBy, ChangeReason, TxId) linked to history rows using SysStartTime and CustomerId as keys.
3) Query history (time travel)
-- Get record as of a point in time
SELECT *
FROM dbo.Customer
FOR SYSTEM_TIME AS OF '2025-11-01 10:00:00'
WHERE CustomerId = '...';
-- Get all versions
SELECT * FROM dbo.Customer
FOR SYSTEM_TIME ALL
WHERE CustomerId = '...'
ORDER BY SysStartTime;
4) Revert to historical version (pattern)
To revert, read the historical row and insert a new current row (or update current row) — do not “restore” history row directly; write a new change so revert is also tracked.
Option B — Manual history table + trigger (flexible, full metadata)
1) Schema (example for table Customer)
CREATE TABLE dbo.Customer
(
CustomerId UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
Name NVARCHAR(200),
Email NVARCHAR(200),
Balance DECIMAL(18,2)
);
CREATE TABLE dbo.CustomerHistory
(
HistoryId BIGINT IDENTITY PRIMARY KEY,
CustomerId UNIQUEIDENTIFIER NOT NULL,
ChangeType CHAR(1) NOT NULL, -- I/U/D
ChangedBy NVARCHAR(200) NULL,
ChangeReason NVARCHAR(500) NULL,
ChangedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
PayloadJson NVARCHAR(MAX) NOT NULL, -- full row snapshot as JSON
TxId UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID()
);
CREATE INDEX IX_CustomerHistory_CustomerId ON dbo.CustomerHistory(CustomerId);
CREATE INDEX IX_CustomerHistory_TxId ON dbo.CustomerHistory(TxId);
2) Trigger to capture changes
CREATE TRIGGER trg_Customer_Audit
ON dbo.Customer
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
SET NOCOUNT ON;
DECLARE @txid UNIQUEIDENTIFIER = NEWID();
DECLARE @changedBy NVARCHAR(200) = SUSER_SNAME(); -- replace via CONTEXT_INFO if app sets
-- INSERTED rows -> Insert
INSERT INTO dbo.CustomerHistory (CustomerId, ChangeType, ChangedBy, ChangeReason, PayloadJson, TxId)
SELECT i.CustomerId, 'I', @changedBy, NULL, (SELECT i.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), @txid
FROM inserted i;
-- UPDATED rows -> Update (capture new snapshot or both old & new as needed)
INSERT INTO dbo.CustomerHistory (CustomerId, ChangeType, ChangedBy, ChangeReason, PayloadJson, TxId)
SELECT u.CustomerId, 'U', @changedBy, NULL, (SELECT u.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), @txid
FROM inserted u
WHERE EXISTS (SELECT 1 FROM deleted d WHERE d.CustomerId = u.CustomerId);
-- DELETED rows -> Delete
INSERT INTO dbo.CustomerHistory (CustomerId, ChangeType, ChangedBy, ChangeReason, PayloadJson, TxId)
SELECT d.CustomerId, 'D', @changedBy, NULL, (SELECT d.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), @txid
FROM deleted d;
END

Join the conversation! Your thoughts help the community grow.