SCHUFA's 'Shadow Database': The Soft-Delete Mistake Behind a 69-Million-Person GDPR Complaint
On 26 August 2026, the Austrian privacy nonprofit noyb sent a formal cease-and-desist letter to SCHUFA, Germany's largest credit reporting agency, and opened an interest list for a potential class action covering an estimated €500 in damages per affected person. The underlying research — first reported by NDR and Süddeutsche Zeitung in mid-July 2026 — found that SCHUFA holds data on roughly 69 million consumers, close to Germany's entire adult population, and that a meaningful share of it has quietly outlived its deletion deadline.
The mechanism is the part every engineering team building on EU infrastructure should read closely. According to noyb and the reporting it's based on (ppc.land has the fuller technical writeup, corroborated by MLex and The Local), SCHUFA doesn't dispute that it retains data past its stated deletion periods. Its defense is architectural: the data is "hidden" from the consumer-facing view — it "disappears from view of consumers, but not from SCHUFA's systems" — while continuing to be processed in the background and, per noyb's allegation, used for third parties' "credit score validations." When consumers exercise their Article 15 right of access, SCHUFA reportedly limits the response to the current score inputs, on the argument that a data subject is "only interested in which data is currently factored into their score," and withholds the retained historical records entirely. noyb says this produces roughly 1.6 million incomplete access responses a year.
Strip away the credit-scoring specifics and what's left is a pattern that shows up in an enormous number of SaaS codebases, usually introduced with zero malicious intent: deleted_at is not deletion.
What SCHUFA is actually accused of
Three claims, mapped to GDPR articles as reported by noyb and independently corroborated in the press coverage above:
| Allegation | GDPR basis | What it means technically |
|---|---|---|
| Data retained beyond stated deletion periods | Art. 5(1)(e) — storage limitation | Records kept "for no longer than is necessary" for the stated purpose; SCHUFA's own retention schedule (reportedly up to 10 years for archived data, 5 years for credit-inquiry records) is what's allegedly being exceeded |
| Data hidden from the consumer view but still live and processed for third parties | Art. 17(1) — right to erasure | A user-facing "deleted" flag with a backend that still queries, joins, and exports the same rows is not erasure — it's UI theater |
| Art. 15 responses limited to current data, excluding retained historical records | Art. 15(1) and Art. 15(3) — right of access | If the data still exists in your systems (even "archived"), it's in scope for a subject access request — you don't get to define it out of existence because it's not shown in the product |
None of this requires SCHUFA to have acted in bad faith for it to be a violation. It requires only that "deleted for the user" and "deleted in the database" diverged, and that the divergence became permanent instead of transitional. That's the exact failure mode of soft-delete-only architectures, and it's worth being precise about why, because the fix is not "never soft-delete."
Soft-delete is fine. Soft-delete without a hard-delete destination is the violation.
Soft-delete (a deleted_at timestamp or status = 'deleted' flag instead of a DELETE FROM statement) is a legitimate, even recommended, pattern for a narrow set of reasons: undo windows, referential integrity while related records are cleaned up, and — genuinely — some of the Art. 17(3) exemptions (tax records, AML checks, active litigation holds) that require you to keep certain data even after an erasure request. sota.io's own Art.15–17 developer guide covers the six exemptions and the retention matrix in detail — the pattern the guide recommends is explicitly "soft-delete for internal tracking; hard-delete triggers erasure cascade."
The problem is what happens after the soft-delete flag is set. There are exactly two legitimate end states:
- A time-boxed grace period, followed by an actual hard delete (or cryptographic shredding of the encryption key, which is functionally equivalent and often cheaper at scale — see our crypto-shredding implementation guide for the self-hosted version of this pattern).
- A documented, per-record Art. 17(3) exemption — a specific legal basis, a specific retention clock tied to that basis, and a system that still counts the record as "erased" for every purpose except the one the exemption actually covers.
What noyb alleges SCHUFA built is a third, illegitimate state: indefinite retention, framed as deletion to the user, with the underlying data remaining fully queryable and — critically — still being used for something (third-party score validation) that has nothing to do with the stated legal basis for retaining it. If the retained records were only ever touched by an auditor responding to a regulatory inquiry, the storage-limitation argument would be weaker. The fact that they were reportedly fed back into a live commercial process is what turns "we kept it a bit long" into "we didn't actually delete it, we just stopped showing it to the person who asked."
The architecture pattern that avoids this
The fix is a genuine state machine with an expiry, not a flag with no expiry. In practice this means three things: a real hard-delete job with a bounded grace window, a legal-basis field on every retained-past-request row, and — separately — an Art. 15 export path that reads from the same source of truth as the retention system, not from whatever the product UI happens to display.
-- Erasure requests get a queue entry, not an immediate cascading DELETE.
-- This buys you the grace period AND the backup-restore problem (Art.17
-- applies to backups too — see the EDPB's Guidelines 2/2019 on this).
CREATE TABLE erasure_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subject_id UUID NOT NULL REFERENCES users(id),
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- NULL until every downstream system confirms the purge
completed_at TIMESTAMPTZ,
-- how long until hard delete actually runs (default: no grace period
-- unless you have a specific undo-window product requirement)
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '30 days'),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','soft_deleted','purged','exempted')),
-- if status = 'exempted', these two fields are MANDATORY, not optional
exemption_basis TEXT, -- e.g. 'art17_3b_tax_retention_hgb257'
exemption_until TIMESTAMPTZ -- when the exemption itself expires
);
-- The check that makes "shadow database" architecturally impossible:
-- you cannot mark a row exempted without a basis and an expiry.
ALTER TABLE erasure_requests
ADD CONSTRAINT exemption_requires_basis
CHECK (status != 'exempted' OR (exemption_basis IS NOT NULL AND exemption_until IS NOT NULL));
# Nightly job: anything past its grace period gets purged for real.
# Anything exempted gets re-checked — exemptions expire, they are not permanent.
def process_erasure_queue(db):
now = datetime.utcnow()
to_purge = db.query(ErasureRequest).filter(
ErasureRequest.status == "pending",
ErasureRequest.scheduled_for <= now,
)
for req in to_purge:
hard_delete_cascade(req.subject_id) # DB rows, search index, backups queue,
# analytics, third-party processor notices
req.status = "purged"
req.completed_at = now
expired_exemptions = db.query(ErasureRequest).filter(
ErasureRequest.status == "exempted",
ErasureRequest.exemption_until <= now,
)
for req in expired_exemptions:
hard_delete_cascade(req.subject_id)
req.status = "purged"
req.completed_at = now
The rule this enforces: there is no code path that leaves a record in limbo forever. Every retained-past-request row has a clock attached to it, and the clock is tied to an actual legal basis you could show a regulator on request — not "we might need this for scoring later." A field like exemption_basis that has to be filled in with a citation, checked in code review, and expires on its own is what makes "we forgot to actually delete it" structurally different from "we deliberately built a system to keep deleting it forever without saying so."
The Article 15 trap: "current data" is not "your data"
The second half of the SCHUFA allegation is arguably the sharper lesson for anyone who has not thought about credit scoring at all. Article 15(1) entitles a data subject to a copy of the personal data being processed about them — not a copy of the subset your product UI currently surfaces. Art. 15(3) is explicit that this means an actual copy, in a commonly used electronic format, not a description or summary.
If your Art. 15 / DSAR export endpoint queries your primary "live" tables but skips the archive table where soft-deleted or exemption-retained records live, you have built the same gap noyb is alleging against SCHUFA — a data subject who asks "what do you have on me" gets an answer that's true of the product, but false of the database. The fix is mechanical: the DSAR export and the retention/erasure system must read from the same inventory. If a record shows up in erasure_requests as exempted rather than purged, it is, by definition, still "personal data concerning the data subject" under Art. 4(1) — and it belongs in the export, tagged with why it's being retained, not silently omitted because it's not part of the "current" view.
def build_dsar_export(subject_id):
export = {"live_data": query_live_tables(subject_id)}
retained = db.query(ErasureRequest).filter(
ErasureRequest.subject_id == subject_id,
ErasureRequest.status == "exempted",
).all()
if retained:
export["retained_data"] = [
{
"record_type": r.record_type,
"basis": r.exemption_basis,
"retained_until": r.exemption_until.isoformat(),
}
for r in retained
]
return export
A DSAR handler that can only ever return live_data is a handler that was never tested against the case where your own retention system successfully did its job and kept a record around. That's not an edge case for a company with any Art. 17(3) exemptions in active use (tax, AML, litigation hold) — it's the normal case, and it's exactly the gap the SCHUFA allegations describe at scale.
What to check this week
If you're maintaining a multi-tenant SaaS with a "delete my account" flow, the SCHUFA complaint is a good forcing function to actually verify three things, not just assume them:
- Does every
deleted_at/status='deleted'row have an eventual hard-delete or crypto-shred, or does the flag just... sit there? Grep your schema for soft-delete columns and check whether there's a scheduled job that ever acts on them, or whether the flag was added purely to satisfy a UI requirement. - Does anything outside the "deleted" user's own view still query soft-deleted rows for a purpose unrelated to the stated retention basis? A scoring model, a recommendation engine, or an analytics pipeline that still ingests "deleted" records is the exact SCHUFA pattern — processing continuing "behind the scenes" for a purpose that has nothing to do with why the data was allowed to be retained.
- Does your Art. 15 export read from the retention system, or from the product database? If they're two different sources of truth, they will drift, and the drift is what turns into an incomplete-access-response finding.
None of this is exotic engineering — it's a state machine with an expiry and a join that includes the archive table. The SCHUFA case is a useful reminder of what it costs to skip it: not a hypothetical future audit, but 69 million people and a class-action interest list opened within days of the first regulator-adjacent letter landing.
sota.io helps EU-based teams turn compliance obligations like Art. 15 DSAR handling and Art. 17 erasure into testable code paths instead of manual checklists — including retention-clock enforcement and export pipelines that read from the same source of truth your erasure system does.
EU-Native Hosting
Ready to move to EU-sovereign infrastructure?
sota.io is a German-hosted PaaS — no CLOUD Act exposure, no US jurisdiction, full GDPR compliance by design. Deploy your first app in minutes.