<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Derek Cai</title>
    <description>The latest articles on DEV Community by Derek Cai (@caiderek).</description>
    <link>https://dev.to/caiderek</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4148409%2F3317f790-2c54-4eca-a796-1e40b192cf81.jpg</url>
      <title>DEV Community: Derek Cai</title>
      <link>https://dev.to/caiderek</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/caiderek"/>
    <language>en</language>
    <item>
      <title>How I Recovered Deleted SQL Server Rows Without Ever Enabling CDC or Audit</title>
      <dc:creator>Derek Cai</dc:creator>
      <pubDate>Tue, 29 Sep 2026 05:57:28 +0000</pubDate>
      <link>https://dev.to/caiderek/how-i-recovered-deleted-sql-server-rows-without-ever-enabling-cdc-or-audit-m10</link>
      <guid>https://dev.to/caiderek/how-i-recovered-deleted-sql-server-rows-without-ever-enabling-cdc-or-audit-m10</guid>
      <description>&lt;p&gt;Most data-recovery tools assume you turned on Change Data Capture, Change Tracking, or Audit &lt;em&gt;before&lt;/em&gt; the incident happened. In the real world — small and mid-sized companies running their own SQL Server — almost nobody does that. By the time someone notices bad data, it's already too late to turn those features on retroactively.&lt;/p&gt;

&lt;p&gt;SQL Server's transaction log already records every change. The problem is &lt;code&gt;fn_dblog&lt;/code&gt;, the function that reads it, is almost entirely undocumented — the row image byte layout isn't published anywhere. The only way to figure it out was to run real experiments against a live database and reverse-engineer the format from actual output, byte by byte.&lt;/p&gt;

&lt;p&gt;That's what became &lt;strong&gt;LogCarver&lt;/strong&gt;: a tool that reads &lt;code&gt;fn_dblog&lt;/code&gt; directly and reconstructs a table's full INSERT / UPDATE (before &amp;amp; after values) / DELETE history, with reviewable Undo and Replay SQL — no prior setup required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two bugs that only showed up on real data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After shipping it, I tested it against a perfectly ordinary table — one with no clustered index (a Heap table, which is also what you get from a &lt;code&gt;PRIMARY KEY NONCLUSTERED&lt;/code&gt;). Result: 0 events found. Worse, the tool's own error message &lt;em&gt;sounded&lt;/em&gt; reasonable — it said the VLF had probably been recycled and the data was likely gone.&lt;/p&gt;

&lt;p&gt;That explanation was wrong.&lt;/p&gt;

&lt;p&gt;Digging into &lt;code&gt;fn_dblog&lt;/code&gt;'s raw output, the real cause was much simpler and much worse: row operations on Heap tables are tagged with &lt;code&gt;LCX_HEAP&lt;/code&gt; in their Context field, but my filter only recognized &lt;code&gt;LCX_CLUSTERED&lt;/code&gt; and &lt;code&gt;LCX_MARK_AS_GHOST&lt;/code&gt;. Every single Heap-table record had been silently excluded from the start — nothing to do with VLF recycling at all. If I hadn't gone back to the raw &lt;code&gt;fn_dblog&lt;/code&gt; output, I would have believed my own tool's plausible-sounding, wrong explanation and moved on.&lt;/p&gt;

&lt;p&gt;Fixing that surfaced a second bug: any table with &lt;em&gt;any&lt;/em&gt; extra index — a &lt;code&gt;NONCLUSTERED PRIMARY KEY&lt;/code&gt; on a Heap table, or just an ordinary index someone added for query performance — broke the table-matching logic, which guessed at the right &lt;code&gt;AllocUnitName&lt;/code&gt; string pattern from the table name. An index's own internal maintenance records use a similar naming pattern, so they got matched too, mixing two completely unrelated pieces of internal SQL Server bookkeeping together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: stop guessing at string patterns. Query &lt;code&gt;sys.indexes&lt;/code&gt; for the table's actual storage structure (&lt;code&gt;index_id&lt;/code&gt; 0 = Heap, 1 = clustered index), build the exact &lt;code&gt;AllocUnitName&lt;/code&gt; from that, and match exactly instead of loosely. The same missing &lt;code&gt;index_id&lt;/code&gt; filter existed in the schema-reading query too — once fixed, extra indexes stopped polluting the column layout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule that held throughout&lt;/strong&gt;: every assumption gets checked against a real SQL Server instance, never just inferred from docs or intuition — because for &lt;code&gt;fn_dblog&lt;/code&gt;, there mostly aren't any docs. None of these three bugs were found by reading documentation; all three were found by connecting to a real database and looking at what &lt;code&gt;fn_dblog&lt;/code&gt; actually returned. I reran the same test matrix against SQL Server 2016, 2019, and 2022 the same day to confirm the fix held across versions before calling any of them "validated."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Current limitations&lt;/strong&gt;: only &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;datetime2&lt;/code&gt;, &lt;code&gt;char&lt;/code&gt;/&lt;code&gt;nchar&lt;/code&gt;, &lt;code&gt;varchar&lt;/code&gt;/&lt;code&gt;nvarchar&lt;/code&gt; are decoded so far — &lt;code&gt;decimal&lt;/code&gt;/&lt;code&gt;money&lt;/code&gt; are still on the list. &lt;code&gt;TRUNCATE TABLE&lt;/code&gt; is currently (over-cautiously) treated like a schema change, which hides events before it; known, not yet fixed.&lt;/p&gt;

&lt;p&gt;LogCarver is MIT-licensed: &lt;a href="https://github.com/caiderek/LogCarver" rel="noopener noreferrer"&gt;github.com/caiderek/LogCarver&lt;/a&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>sql</category>
      <category>tools</category>
    </item>
  </channel>
</rss>
