SQL Server temporal tables copy the whole row into history on every update. A large JSON document sitting on a busy row (mission briefing, workflow payload, form answers — often tens of KB of nvarchar(max)) gets rewritten into history even when the JSON did not change. That can grow history by tens of GB a month and fill the data file, while the live table itself still looks fine.
Treat large JSON as a document, not as another column on a hot temporal row. Compress it, and when other fields on the parent change often, move it to its own 1:1 table. See Do you use temporal tables to audit data changes? and Do you keep database tables small?.
Keep the document as a string in the domain model. Persist it as varbinary(max) through a shared EF Core ValueConverter that gzip-compresses on the way in and decompresses on the way out.
Encode the text as UTF-16LE before gzip so the bytes interoperate with SQL Server COMPRESS() / DECOMPRESS() on nvarchar. The app and T-SQL can each read what the other wrote.
public class HeroMission{public Guid Id { get; private set; }public Guid TeamId { get; private set; }public MissionStatus Status { get; private set; }// Tens of KB of briefing JSON rewritten into history on every Status changepublic string BriefingJson { get; private set; } = string.Empty;}
❌ Figure: Bad example - Plain nvarchar(max) briefing on a temporal HeroMission row is copied into history whenever Status changes
public sealed class CompressedJsonConverter : ValueConverter<string, byte[]>{public CompressedJsonConverter(): base(plain => Compress(plain),bytes => Decompress(bytes)){}private static byte[] Compress(string value){// UTF-16LE matches SQL Server COMPRESS() on nvarcharvar utf16 = Encoding.Unicode.GetBytes(value);using var output = new MemoryStream();using (var gzip = new GZipStream(output, CompressionLevel.Optimal)){gzip.Write(utf16);}return output.ToArray();}private static string Decompress(byte[] value){using var input = new MemoryStream(value);using var gzip = new GZipStream(input, CompressionMode.Decompress);using var reader = new StreamReader(gzip, Encoding.Unicode);return reader.ReadToEnd();}}// In OnModelCreating:builder.Entity<MissionBriefingDocument>().Property(x => x.BriefingJson).HasConversion<CompressedJsonConverter>().HasColumnType("varbinary(max)");
✅ Figure: Good example - A shared ValueConverter stores the document as gzip varbinary(max) and stays compatible with SQL COMPRESS/DECOMPRESS
Read compressed values in T-SQL with:
SELECT CAST(DECOMPRESS(BriefingJson) AS nvarchar(max)) AS BriefingJsonFROM dbo.MissionBriefingDocument;
✅ Figure: Good example - T-SQL decompresses the same bytes the EF converter wrote
Keep plain nvarchar only when SQL must query the JSON itself — EF primitive collections / OPENJSON, CDC columns, ISJSON constraints, or framework payloads such as outbox, Hangfire, or Identity grants.
Note: Compress the whole document. Per-property or columnstore tricks do not help when the problem is an unchanged nvarchar(max) being copied into temporal history on every parent update.
Compression alone is not enough if other columns on the parent change constantly. Every parent update still writes a new history row that includes the (now smaller) blob. Split the document out.
| Document kind | Pattern | Why |
|---|---|---|
| Authored / set-up-front JSON that is part of the audit record (briefing, submitted answers) | Own temporal 1:1 table | History only when the JSON itself changes |
| Transient runtime state rewritten constantly (progress, scratch, last-run payload) | Non-temporal 1:1 table | Latest value only — no history to balloon |
// Temporal parent - updated often as the mission runspublic class HeroMission{public Guid Id { get; private set; }public Guid TeamId { get; private set; }public MissionStatus Status { get; private set; }public MissionBriefingDocument? Briefing { get; private set; }}// Temporal 1:1 - history only when the briefing document changespublic class MissionBriefingDocument{public Guid HeroMissionId { get; private set; }public string BriefingJson { get; private set; } = string.Empty;}
✅ Figure: Good example - Authored briefing lives on its own temporal 1:1 table, so Status changes on HeroMission do not rewrite the document into history
// Non-temporal 1:1 - rewritten on every progress tick, no history neededpublic class MissionRuntimeState{public Guid HeroMissionId { get; private set; }public string ProgressJson { get; private set; } = string.Empty;}
✅ Figure: Good example - Transient runtime JSON stays off temporal history entirely
After you migrate:
Do not flip the column type in place on a large table. Use an expand/contract migration (see Do you know to use code migrations?):
varbinary(max) column (or 1:1 table)COMPRESS or the same converter)ValidFrom / ValidTo periods into the new history table (set SYSTEM_VERSIONING = OFF, insert the old versions, then set it back ON with HISTORY_TABLE pointing at that table). Verify row counts and spot-check samples against the old history-- Backfill in small batches using SQL Server COMPRESS (UTF-16LE for nvarchar)WHILE 1 = 1BEGINUPDATE TOP (1000) dbo.HeroMissionSET BriefingJsonCompressed = COMPRESS(BriefingJson)WHERE BriefingJsonCompressed IS NULLAND BriefingJson IS NOT NULL;IF @@ROWCOUNT = 0 BREAK;END
✅ Figure: Good example - SQL COMPRESS backfills the new column in bounded batches with bytes the EF converter can decompress
Run each batch as its own commit, not inside one migration transaction, and ideally off-peak. On a temporal table every batch also adds history rows, so watch log and history growth while it runs.
| Option | Why not |
|---|---|
| Move JSON to blob storage | Adds another system, weakens transactional consistency with the row, and complicates point-in-time restore with temporal history |
| Turn temporal off on the parent | Loses the audit trail you added temporal for |
| Retention-only on history | Caps growth but still rewrites large unchanged JSON on every update until retention deletes it; does not fix write cost |
OPENJSON or EF primitive collections — keep plain nvarchar and index what you need