Retention, Deletion and Anonymisation: "Delete My Account", Soft Delete, Backups, Caches and the Periodic Destruction Job
A "delete my account" request is not one DELETE but a separate decision for every piece of data: the profile is erased, orders are detached from the person and kept for the legal period, then anonymised. On Bookstore we show the foreign key pitfall, why soft delete is not enough, how each copy is erased, the periodic destruction job, the destruction log and how an erasure ledger stops erased customers from returning after a backup restore, with real output.
Version note: The code in this post was built and run with .NET 10, ASP.NET Core 10, Entity Framework Core 10.0.15 (SQLite) and xUnit 2.9. Legal dates and periods were checked against mevzuat.gov.tr and kvkk.gov.tr on 15 November 2026.
This series is a developer's view, not legal advice. Retention periods, and which records are deleted and which are kept, are decided by your legal counsel and accountant; the periods in this post are examples.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, a customer named Linus wants to delete his account. You process the request after the 02:00 backup, and the customer row is deleted. The next week a disk failure forces a restore of the database from that night's backup. Is Linus in the system? And a simpler question: do db.Customers.Remove(linus) and SaveChangesAsync work for a customer who has orders?
Where Retention Periods Come From
The last principle in Article 4 of KVKK, Turkey's Law No. 6698, is that data is kept only as long as the relevant legislation requires or the purpose of processing needs. Article 7 requires the data to be erased, destroyed or anonymised, on the controller's own initiative or at the data subject's request, once the reasons for processing it no longer exist. The procedures are in the Regulation on the Erasure, Destruction or Anonymisation of Personal Data (Official Gazette No. 30224 of 28 October 2017, in force since 1 January 2018). The provisions of the regulation that turn into code:
- Erasure makes the data inaccessible and unusable in any way for the relevant users (Art. 8); destruction makes it inaccessible and unrecoverable for anyone (Art. 9); anonymisation makes it impossible to associate with a person even when matched with other data (Art. 10).
- A controller with a retention and destruction policy destroys the data at the first periodic destruction after the obligation arises; the interval is set in the policy and cannot exceed six months. For those without a policy obligation the period is three months (Art. 11).
- A data subject's request is concluded within thirty days at the latest (Art. 12(1), as amended on 28 April 2019).
- Records of erasure, destruction and anonymisation are kept for at least three years, other legal obligations aside (Art. 7(3)).
For the customer profile the period is "while the account is open". For orders and invoices other laws come in: Article 82 of the Turkish Commercial Code requires commercial books and the documents behind the entries to be kept for ten years, counted from the end of the relevant calendar year, and Article 253 of the Tax Procedure Law sets a five-year duty to keep them. The GDPR makes the same distinction: Art. 5(1)(e) sets the storage limitation principle, Art. 17 grants the right to erasure, and Art. 17(3)(b) excludes cases where a legal obligation requires the processing. The result: a "delete my account" request is not deleting all data, it is a separate decision for every piece of it.
We calculate the retention date when the order is written and store it on the row, so the destruction job compares dates instead of reinterpreting legislation:
// Example periods from the inventory; the legal team owns the real values.
public static class RetentionPolicy
{
public static readonly TimeSpan SignInSessions = TimeSpan.FromDays(180);
// TTK 82: ten years, counted from the end of the calendar year of the record.
public static DateTime OrderBillingRetainUntil(DateTime orderedAtUtc) =>
new DateTime(orderedAtUtc.Year + 1, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddYears(10);
}
The "Delete My Account" Flow
In post 01 we opened the erasure request as an ErasureRequest record with 202 Accepted. The service that processes it applies the inventory's decisions in order: orders stay for the legal period but lose their link to the person; sessions and the profile are deleted; the request is marked complete and a destruction record is written. Copies outside the database are erased after the commit:
public async Task<ErasureResult> EraseCustomerAsync(int customerId, CancellationToken ct = default)
{
DateTime now = clock.GetUtcNow().UtcDateTime;
// ExecuteUpdate/ExecuteDelete bypass the change tracker; drop stale tracked copies first.
db.ChangeTracker.Clear();
await using var tx = await db.Database.BeginTransactionAsync(ct);
// Orders stay for the legal retention period but no longer point to a person.
int orders = await db.Orders.Where(o => o.CustomerId == customerId)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.CustomerId, (int?)null), ct);
int sessions = await db.CustomerSessions.Where(s => s.CustomerId == customerId).ExecuteDeleteAsync(ct);
await db.Customers.Where(c => c.Id == customerId).ExecuteDeleteAsync(ct);
await db.ErasureRequests.Where(r => r.CustomerId == customerId && r.CompletedAtUtc == null)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.CompletedAtUtc, now), ct);
db.Add(new DestructionLogEntry { ExecutedAtUtc = now, Category = "customer-profile", Method = "delete", RecordCount = 1 + sessions, Reason = "erasure request" });
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
// Copies outside the database are erased after the commit; the ledger lets a restore replay this.
List<string> erased = [];
foreach (IPersonalDataCopy copy in copies)
{
await copy.EraseCustomerAsync(customerId, ct);
erased.Add(copy.Name);
}
await ledger.AppendAsync(customerId, now, ct);
return new ErasureResult(customerId, orders, sessions, [.. erased]);
}
The billing name, address and encrypted national ID left on the order are still personal data, so this record is not "erased" but "kept with restricted access for the legal period". Access is limited to the Accounting role, as in post 03. In a real application the same flow also closes the user record, logins and tokens on the Identity side; we covered that in the Authentication and Authorization series post "The Account Lifecycle with ASP.NET Core Identity: Registration, Email Confirmation, Password Reset, External Login and Account Deletion".
The foreign key pitfall and the change tracker
The second half of the opening question: when you try to delete a customer with orders straight through EF Core, the database objects, because the Orders.CustomerId foreign key is defined with Restrict:
plain delete: SQLite Error 19: 'FOREIGN KEY constraint failed'.
This error has two wrong fixes. The first is turning on cascade delete: the orders that must be kept for the legal period go with the customer. The second is giving up on deleting the customer row and setting a flag instead, which is the topic of the next section. The right way is the order above: first cut the links of the dependent rows, then delete. One more EF Core detail came up along the way: ExecuteUpdateAsync and ExecuteDeleteAsync run SQL directly and do not update the change tracker. If a previously loaded Customer instance is tracked in the same context, the old state of the deleted row stays in memory; that is why the test failed with "another instance with the same key value for {'Id'} is already being tracked" when it tried to add a new row with the same key. The ChangeTracker.Clear() call is there for that.
Does Soft Delete Count as Erasure
We covered the soft delete pattern in the Solution Patterns series post "Doing Soft Delete Right: IsDeleted, DeletedAt, Filtered Unique Indexes and Archive Tables" and global query filters in the EF Core series post "Global Query Filters in EF Core: Soft Delete, Multi-Tenancy and IgnoreQueryFilters". With EF Core 10 you can define several named filters on one entity and turn off just one of them with IgnoreQueryFilters(["SoftDeletionFilter"]). All of these keep the application from seeing a soft-deleted row; the row itself stays in the database, in the backups and in every query that calls IgnoreQueryFilters.
Article 8 of the regulation defines erasure as making the data inaccessible and unusable in any way for the relevant users. As far as I read it as a developer, IsDeleted = true does not meet that definition on its own: users, reports and support tools that can reach the data still exist. Soft delete can be the first step for a short undo window; the real erasure is done by the destruction job after that window. The length of the window is itself a retention period and goes into the inventory. Ask the legal team whether this reading fits your case.
Copies: The Database Is Not the Only Place
The "copies" column of the inventory from post 02 turns into a task list here. The copies of a customer record in Bookstore and how each is erased:

A read replica receives the delete from the primary through replication on its own. Copies outside the database, such as the search index and the Redis cache, each get an IPersonalDataCopy implementation; a short TTL in the cache makes the data expire on its own even if the delete call is missed. For logs and telemetry the answer is not deleting but keeping personal data out of them in the first place, plus a retention period; that is the topic of post 05. External providers such as analytics and the e-mail service are handled with the provider's deletion API and retention setting; they are also the sub-processors we discuss in post 07.
Backups are a class of their own: deleting a single customer from a backup file is not practical, and editing a backup makes it untrustworthy. The common engineering practice is to let backups expire on their own retention schedule and to reapply erasures whenever a restore happens. How a copy in a backup is assessed against the regulation's definition of erasure is again a question for the legal team.
The Periodic Destruction Job and the Destruction Log
Erasing customers with pending requests, deleting expired sessions and anonymising orders whose legal period has ended are the work of the same job:
public async Task<DestructionRun> RunAsync(CancellationToken ct = default)
{
DateTime now = clock.GetUtcNow().UtcDateTime;
List<int> pending = await db.ErasureRequests.Where(r => r.CompletedAtUtc == null)
.Select(r => r.CustomerId).Distinct().ToListAsync(ct);
foreach (int customerId in pending)
{
await erasure.EraseCustomerAsync(customerId, ct);
}
DateTime sessionCutoff = now - RetentionPolicy.SignInSessions;
int sessions = await db.CustomerSessions.Where(s => s.SignedInAtUtc < sessionCutoff).ExecuteDeleteAsync(ct);
// After the legal period the order is kept only as an anonymous sales record.
int orders = await db.Orders.Where(o => o.RetainUntilUtc <= now && o.BillingName != "-")
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.CustomerId, (int?)null)
.SetProperty(o => o.BillingName, "-")
.SetProperty(o => o.ShippingAddress, "-")
.SetProperty(o => o.BillingNationalIdEncrypted, (string?)null)
.SetProperty(o => o.BillingNationalIdIndex, (string?)null), ct);
db.AddRange(
new DestructionLogEntry { ExecutedAtUtc = now, Category = "sign-in-session", Method = "delete", RecordCount = sessions, Reason = "retention 180 days" },
new DestructionLogEntry { ExecutedAtUtc = now, Category = "order-billing", Method = "anonymise", RecordCount = orders, Reason = "retention TTK 82" });
await db.SaveChangesAsync(ct);
return new DestructionRun(pending.Count, sessions, orders);
}
The destruction record (DestructionLogEntry) holds no personal data: category, method, record count and reason. That is the evidence of "which operation was done when" the regulation asks for; writing the erased customer's name into it would mean keeping the erased data in another table. The amount and date left on an anonymised order do not point to a person by themselves, but in a small data set you still need to check whether they can be matched with other data; the anonymity bar from post 03 applies here too.
A BackgroundService in the API runs the job once a day; the regulation sets a six-month upper limit, and a daily run keeps the backlog small and comfortably meets the 30-day request period. In a multi-instance environment the job must not run twice, must produce the same result when it runs again, and must resume where it stopped after an interruption; we covered those with Hangfire and Quartz.NET in the Background Jobs and Scheduled Jobs series. The log of the job's first run as the API starts:
info: Bookstore.Api.DestructionWorker[0]
Destruction run: 0 erasure requests, 6 sessions deleted, 0 orders anonymised
Restoring a Backup: The Erased Customer Comes Back
We saw the answer to the opening question with a demo. The lab command writes six synthetic customers, each with two orders (one from 2014, one from this year) and two sessions (one from 200 days ago); then it takes the nightly backup, tries a plain delete, processes Linus's request with the job, and finally restores the database from the backup:
cd tools/Bookstore.PrivacyLab
dotnet run -- erase
plain delete: SQLite Error 19: 'FOREIGN KEY constraint failed'.
run: DestructionRun { ErasureRequests = 1, SessionsDeleted = 5, OrdersAnonymised = 6 }
customers=5 orders=12 orphan orders=7 search-index=[1,2,3,5,6]
log: customer-profile delete 3 (erasure request)
log: sign-in-session delete 5 (retention 180 days)
log: order-billing anonymise 6 (retention TTK 82)
after restore: Linus present = True
ledger replay: 1 customer(s) erased again, Linus present = False
Let's read the lines. The job processed one request: Linus's profile and two sessions were deleted (3 records), and his two orders stayed but were detached from the person. The other five customers' 200-day-old sessions were deleted. The retention period of the 2014 orders ended on 1 January 2025, so six orders were anonymised. After the restore Linus was back: the backup had been taken before the request was processed. What brings the erasure back is the erasure ledger, kept outside the main database and its backups:
// Run after restoring a backup: customers erased after the backup was taken come back otherwise.
public async Task<int> ReapplyLedgerAsync(CancellationToken ct = default)
{
int count = 0;
foreach (int id in await ledger.ErasedCustomerIdsAsync(ct))
{
if (await db.Customers.AnyAsync(c => c.Id == id, ct))
{
await EraseCustomerAsync(id, ct);
count++;
}
}
return count;
}
The ledger itself is in the inventory too: it holds only the customer number and the erasure time, has no link to any other data, and its only purpose is reapplying erasures after a restore. If the ledger lived in the main database, it would also go back to its old state from the same backup, and the demo's last line would stay Linus present = True. Anonymising expired orders does not need the ledger: the periodic job compares dates, so its first run after the restore does the same work again.
Production Notes
If the ledger replay step is not in the restore runbook, this code never runs; it has to be a step that runs after the restore and before the application takes traffic. The second point is observing the job: the counts in the destruction log should reach a dashboard, and an alert should fire if the job did not run at all or deleted an unexpectedly large number of records. A job that deletes every session because of a filter bug is as much of a problem as a job that never runs. The third is the cost of bulk deletes: a single ExecuteDeleteAsync on a table with millions of rows means a long transaction and locks; the work has to be split into small batches. The fourth is the reliability of external copies: in this sample the copies are erased one by one after the commit; if one fails, the request looks complete while the data stays in the search index. In production this step should be written as an outbox record inside the transaction and retried for each copy separately until it succeeds; we covered the outbox pattern in the Messaging and Event-Driven Architecture series post "Transactional Outbox and Inbox: Understanding and Designing Around the Dual Write Problem".
Common Pitfalls
The most common pitfall is "fixing" the foreign key error with cascade delete and deleting orders that must be kept for the legal period. The second is believing soft delete is erasure and leaving IsDeleted rows in the table for years. The third is erased customers coming back after a backup restore; keeping the ledger in the main database does not solve it. The fourth is writing the erased person's details into the destruction log. The fifth is writing periodic destruction only for the main database and forgetting the search index, the cache and analytics; the copies column from post 02 is the checklist for that.
Sources for This Post
Accessed on 15 November 2026.
- Law No. 6698, Art. 4 and 7:
mevzuat.gov.tr/mevzuatmetin/1.5.6698.pdf - Regulation on the Erasure, Destruction or Anonymisation of Personal Data (Art. 7, 8, 9, 10, 11, 12):
kvkk.gov.tr/Icerik/5441 - Turkish Commercial Code No. 6102, Art. 82:
mevzuat.gov.tr/MevzuatMetin/1.5.6102.pdf; Tax Procedure Law, Art. 253:gib.gov.tr/mevzuat/kanun/434 - GDPR Art. 5(1)(e) and 17:
eur-lex.europa.eu/eli/reg/2016/679/oj; EF Core 10 named query filters:learn.microsoft.com/ef/core/what-is-new/ef-core-10.0/whatsnew
A Small Experiment
★☆☆ Comment out the ledger replay line in the erase demo and see that Linus stays in the system after the restore. Then move the ledger file into the main database as a table and run the same experiment.
★★☆ Add a soft delete step to Customer: when a request arrives, set DeletedAtUtc, let the customer change their mind within 7 days, and have the job really delete rows older than 7 days. Add those 7 days to the inventory and to RetentionPolicy.
★★★ Write a version that deletes sessions in batches of 1,000 and records progress after each batch; test that when the job is interrupted and restarted it does not count the same rows twice and that the destruction log total stays correct.
So far we have managed personal data in the database and its copies. In the next post we look at its quietest copies: URLs, exception messages and IP addresses in logs, traces, analytics and error reporting tools.