Preface#
A support ticket lands in your queue:
From: angry.user@example.com Subject: GDPR - Delete ALL my data immediately
I am exercising my right to erasure under GDPR Article 17.
Delete ALL my personal data from ALL your systems within 30 days.
I want written confirmation when complete.
If you fail to comply, I will file a complaint with my national data protection authority.
You check where this user's data lives — user data locations
| Category | Where |
|---|---|
| Primary Systems | PostgreSQL (users table, orders, preferences) · MongoDB (activity logs, user-generated content) · Elasticsearch (user profile in search index) · Redis (session cache, user preferences cache) · S3 (profile photos, uploaded documents) |
| Analytics & Data Warehouse | BigQuery (event data, behavioral analytics) · Mixpanel (product analytics) · Amplitude (user journeys) |
| Third-Party Services | Stripe (payment history) · Zendesk (support tickets) · Intercom (chat history) · Mailchimp (email list) · HubSpot (CRM records) |
| Backups | PostgreSQL backups (daily kept 30 days, weekly kept 90 days) · MongoDB snapshots (30 day retention) · S3 versioning (infinite retention... oops) |
| Logs | Application logs (Datadog - 15 day retention) · Access logs (CloudWatch - 90 day retention) · Audit logs (S3 - 7 year retention for compliance) |
| Derived Data | ML training datasets · Aggregated reports · Anonymized analytics |
Question: Can you prove you deleted everything? Answer: Not without a system designed for this.
Today, we'll build a deletion system that can answer "yes" with confidence.
A GDPR Article 17 request must be answered within a month, but the user's data sits in a dozen systems, from PostgreSQL and Elasticsearch to Stripe, backups and logs, and only hard deletion or true anonymization counts. A data inventory records each element's locations and deletion method, each system type gets a strategy (immediate, 24 to 48 hours, or expiry), and foreign keys force leaf records first and the user last. The deletion service records the request as PENDING with a 30-day deadline, runs targets in priority order, verifies every system and writes an anonymized audit record; backups expire and are re-applied on restore, orders are anonymized and kept for tax law, shared messages show @deactivateduser and audit logs are anonymized, never deleted.
Part I: Foundations#
Chapter 1: Understanding the Right to Erasure#
1.1 What GDPR Article 17 Requires#
When it applies — user can request deletion when:
- Data no longer necessary for original purpose
- User withdraws consent (and no other legal basis exists)
- User objects to processing (and no overriding legitimate grounds)
- Data was unlawfully processed
- Legal obligation to erase
- Data collected from children for online services
When you can refuse
- Freedom of expression and information
- Legal obligation requiring processing
- Public health purposes
- Archiving in public interest, research, statistics
- Establishment, exercise, or defense of legal claims
Timeline
- Response required: Within 1 month
- Extension possible: Up to 2 additional months for complex cases
- Must inform user of extension within first month
- Must explain reasons if refusing
What to delete
- All personal data about the individual
- All copies (including backups, eventually)
- Instruct your processors to delete (they act on your behalf)
- Notify recipients you disclosed the data to (Art. 19); if you made it public, take reasonable steps to inform other controllers (Art. 17(2))
1.2 The Deletion Complexity Problem#
Problem 1: Data fragmentation
- Data spread across dozens of systems
- Each system has different deletion API
- Some systems don't have deletion APIs
- No central registry of where data lives
Problem 2: Data relationships
- User has orders → orders have items → items have reviews
- Deleting user breaks foreign key constraints
- What happens to their reviews? Comments? Shared content?
- Cascade vs soft delete vs anonymize?
Problem 3: Backups
- User data in weekly backup from 3 months ago
- Can't surgically remove one user from backup
- Options: Keep backup (delay deletion) or destroy backup
- Need clear retention policy that accounts for this
Problem 4: Third parties
- Stripe has payment history
- Zendesk has support tickets
- You're the controller, they're processors
- You must ensure they delete too
Problem 5: Derived data
- ML model trained on user's data
- Can't "untrain" a model
- Aggregated statistics include user
- Where's the line between personal and anonymous?
Problem 6: Proving deletion
- Auditor asks: "Prove user X was deleted"
- If you deleted everything, how do you prove it?
- Need audit trail of deletion itself
- Paradox: Must keep record of deletion
Design it: three kinds of data, one request
The angry user has a profile (name, email, phone), 40 orders your accountants must keep for tax, and 200 comments in threads other people replied to. For each, would you delete it, keep it, or something in between? And is setting deleted = true on the user row enough?
One strong answer
- Profile: hard delete. It's purely personal data and nothing else needs it.
- Orders: anonymize: drop the link to the user and the addresses, keep amounts and dates, because the law requires the financial record.
- Comments: anonymize the author ("Deleted User") so other people's replies keep their context.
- A deleted flag alone isn't deletion: the data still exists and sits in backups. It's only a step before the hard delete.
1.3 What "Deletion" Actually Means#
| Strategy | What happens | Notes |
|---|---|---|
| HARD DELETE | Data physically removed from storage · Cannot be recovered | Appropriate for: Most personal data · Challenge: May break referential integrity |
| SOFT DELETE | Data marked as deleted but still exists · Excluded from queries | NOT compliant with GDPR by itself · Use as: Intermediate state before hard delete |
| ANONYMIZATION | Remove all identifying information · Remaining data cannot identify anyone | Can be retained indefinitely · Challenge: True anonymization is hard |
| PSEUDONYMIZATION | Replace identifiers with pseudonyms | Still personal data under GDPR! · NOT sufficient for deletion request · Can be used for: Active data protection |
| AGGREGATION | Combine with other data to remove individual · "User X had 5 orders" → "Users made 10,000 orders" | If truly anonymous, can retain · Challenge: Ensuring k-anonymity |
Chapter 2: Data Inventory and Mapping#
2.1 Building a Data Map#
For each personal data element, document:
Data Element: user_email
Locations
- Primary:
PostgreSQL.users.email - Search:
Elasticsearch.users.email - Cache:
Redis.user:{id}:profile.email - Analytics:
BigQuery.events.user_email - Third-party:
Stripe.customers.email
Retention
- Primary: Until account deletion
- Backups: 90 days after primary deletion
- Logs: 15 days (then auto-purged)
Deletion method
- PostgreSQL:
DELETE FROM users WHERE id = ? - Elasticsearch:
DELETE /users/_doc/{id} - Redis:
SCAN MATCH user:{id}:*, thenUNLINK/DELthe keys found (DELtakes literal key names, not patterns) - BigQuery:
DELETE FROM events WHERE user_id = ? - Stripe:
stripe.Customer.delete(customer_id)
Dependencies
- Orders table references
user_id - Comments table references
user_id - Notifications table references
user_id
LEGAL BASIS: Contract performance DATA CONTROLLER: Our Company DATA PROCESSORS: Stripe, SendGrid
2.2 Personal Data Categories#
| Category | Examples | Deletion |
|---|---|---|
| DIRECTLY IDENTIFYING | Name, email, phone · Government IDs (SSN, passport) · Financial account numbers · Biometric data | Must delete or anonymize |
| INDIRECTLY IDENTIFYING | IP addresses · Device identifiers · Cookie IDs · Location data (precise) · Behavioral patterns | Must delete or anonymize |
| SENSITIVE (SPECIAL CATEGORIES) | Health information · Racial or ethnic origin · Religious or philosophical beliefs · Political opinions · Trade union membership · Sexual orientation · Biometric data for identification · Genetic data | Priority deletion, extra care |
| DERIVED DATA | Predictions and scores · Segments and categories · Behavioral models · Recommendations | Usually delete with source data |
| AGGREGATED DATA | Statistics where individual not identifiable · Counts, averages, distributions · k-anonymous datasets (k>10 typically) | May retain if truly anonymous |
Chapter 3: Deletion Strategies by System Type#
3.1 Strategy Matrix#
| System Type | Strategy | Timeline | Verification |
|---|---|---|---|
| Primary DB | Hard delete | Immediate | Query returns null |
| Search Index | Delete document | Immediate | Search returns 0 |
| Cache | Delete keys | Immediate | Key not found |
| File Storage | Delete objects | Immediate | 404 on access |
| Analytics | Delete or anon | 24-48 hours | Query returns 0 |
| Third-party | API call | Varies | Confirmation email |
| Backups | Let expire | Per policy | Retention tracking |
| Logs | Let expire | Per policy | Retention tracking |
| ML Models | Retrain without | Next cycle | Model version |
Erasure foundations
Q1/3 A teammate handles erasure requests by setting
deleted = trueon the user row and filtering those rows out of every query. What is wrong with that?Answer & explanation
Answer: B
Soft delete leaves the personal data in storage, queryable by mistake and present in backups, so the lesson treats it as an intermediate state only. Pseudonymizing does not help either, because pseudonymized data is still personal data.
Q2/3 An erasure request arrives for a user whose data sits in 90 days of database backups. What does the lesson's strategy matrix prescribe for those backups?
Answer & explanation
Answer: D
You cannot surgically remove one user from a backup, so backups are deleted "eventually" by expiry, with retention tracking as the proof. Rewriting every snapshot is impractical, and backups are not exempt; the retention policy has to account for them.
Q3/3 After a deletion request, which of these may you keep?
Answer & explanation
Answer: A
Truly anonymous aggregates can be retained. Pseudonyms and reversible tokens are still personal data, and derived data such as scores and segments is usually deleted with its source.
Part II: Implementation#
Chapter 4: Deletion Service Architecture#
What breaks if you delete in the wrong order?
An engineer proposes deleting in this order: clear the Redis cache, delete the PostgreSQL user, then call analytics, search and Stripe. Name two things that go wrong.
One strong answer
- The cache refills. Between clearing Redis and deleting the row, any read misses the cache, loads the still-present user and caches them again. Caches go after the primary database.
- You lose the map. Once the user row is gone, later steps may no longer be able to find the user's analytics rows or Stripe customer. The primary database goes after the copies that need its IDs.
- And if one of those later calls fails, you can no longer retry it, because the information needed to find the data is gone.
4.1 Core Deletion Service#
# deletion/service.py
"""
User data deletion service.
Orchestrates deletion across all systems where user data exists.
"""
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any
from datetime import datetime, timedelta, timezone
from enum import Enum
import uuid
import logging
import asyncio
logger = logging.getLogger(__name__)
class DeletionStatus(Enum):
"""Status of a deletion request."""
PENDING = "pending"
IN_PROGRESS = "in_progress"
AWAITING_VERIFICATION = "awaiting_verification"
COMPLETED = "completed"
PARTIALLY_COMPLETED = "partially_completed"
FAILED = "failed"
CANCELLED = "cancelled"
class SystemDeletionStatus(Enum):
"""Status of deletion in a specific system."""
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
NOT_APPLICABLE = "not_applicable"
@dataclass
class DeletionTarget:
"""A system that needs deletion."""
system_name: str
system_type: str # database, cache, storage, third_party, etc.
deletion_method: str # How to delete in this system
priority: int # Order of deletion (lower = first)
status: SystemDeletionStatus = SystemDeletionStatus.PENDING
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
error_message: Optional[str] = None
records_deleted: int = 0
@dataclass
class DeletionRequest:
"""A request to delete a user's data."""
id: str
user_id: str
tenant_id: str
requested_at: datetime
requested_by: str # user, admin, automated
reason: str
status: DeletionStatus
targets: List[DeletionTarget]
deadline: datetime # GDPR 30 days
completed_at: Optional[datetime] = None
verified_at: Optional[datetime] = None
verification_report: Optional[Dict] = None
class DeletionService:
"""
Orchestrates user data deletion across all systems.
Key responsibilities:
- Accept deletion requests
- Coordinate deletion across systems
- Track progress and handle failures
- Verify deletion completion
- Maintain audit trail
"""
# Systems in deletion order (dependencies first)
DELETION_TARGETS = [
# First: Search indexes
DeletionTarget("elasticsearch", "search", "delete_document", priority=2),
# Second: File storage
DeletionTarget("s3_files", "storage", "delete_objects", priority=3),
DeletionTarget("s3_uploads", "storage", "delete_objects", priority=3),
# Third: Analytics (before primary DB to capture user_id mapping)
DeletionTarget("bigquery", "analytics", "delete_rows", priority=4),
DeletionTarget("mixpanel", "analytics", "delete_user", priority=4),
# Fourth: Third-party services
DeletionTarget("stripe", "third_party", "delete_customer", priority=5),
DeletionTarget("zendesk", "third_party", "delete_user", priority=5),
DeletionTarget("intercom", "third_party", "delete_user", priority=5),
DeletionTarget("mailchimp", "third_party", "unsubscribe_delete", priority=5),
# Then: Primary database (has foreign keys)
DeletionTarget("postgresql", "database", "delete_cascade", priority=10),
# Last: Caches, AFTER the primary DB, so a concurrent read can't
# refill them from rows that haven't been deleted yet
DeletionTarget("redis_cache", "cache", "delete_keys", priority=11),
DeletionTarget("cdn_cache", "cache", "purge_urls", priority=11),
]
def __init__(
self,
db,
deletion_executors: Dict[str, 'DeletionExecutor'],
event_publisher,
notification_service
):
self.db = db
self.executors = deletion_executors
self.events = event_publisher
self.notifications = notification_service
async def create_deletion_request(
self,
user_id: str,
tenant_id: str,
requested_by: str,
reason: str
) -> DeletionRequest:
"""
Create a new deletion request.
This starts the deletion process.
"""
request_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
# Determine which targets apply to this user
targets = await self._determine_targets(user_id, tenant_id)
request = DeletionRequest(
id=request_id,
user_id=user_id,
tenant_id=tenant_id,
requested_at=now,
requested_by=requested_by,
reason=reason,
status=DeletionStatus.PENDING,
targets=targets,
deadline=now + timedelta(days=30) # GDPR deadline
)
# Store request
await self._save_request(request)
# Publish event
await self.events.publish("deletion", {
"type": "deletion.requested",
"request_id": request_id,
"user_id": user_id,
"tenant_id": tenant_id
})
logger.info(
f"Deletion request created",
extra={
"request_id": request_id,
"user_id": user_id,
"targets": len(targets)
}
)
return request
async def execute_deletion(self, request_id: str) -> DeletionRequest:
"""
Execute a deletion request.
Coordinates deletion across all target systems.
"""
request = await self.get_request(request_id)
retryable = [
DeletionStatus.PENDING,
DeletionStatus.FAILED,
DeletionStatus.PARTIALLY_COMPLETED,
]
if request.status not in retryable:
raise ValueError(f"Cannot execute request in status: {request.status}")
request.status = DeletionStatus.IN_PROGRESS
await self._save_request(request)
# Group targets by priority (on a retry, skip systems already done)
priority_groups = {}
for target in request.targets:
if target.status == SystemDeletionStatus.COMPLETED:
continue
if target.priority not in priority_groups:
priority_groups[target.priority] = []
priority_groups[target.priority].append(target)
# Execute in priority order
all_succeeded = True
for priority in sorted(priority_groups.keys()):
targets = priority_groups[priority]
# Execute targets at same priority level in parallel
results = await asyncio.gather(
*[self._execute_target(request, target) for target in targets],
return_exceptions=True
)
# Check for failures
for target, result in zip(targets, results):
if isinstance(result, Exception):
target.status = SystemDeletionStatus.FAILED
target.error_message = str(result)
all_succeeded = False
logger.error(
f"Deletion failed for {target.system_name}: {result}",
extra={"request_id": request_id}
)
if not all_succeeded:
# Stop before later groups: PostgreSQL must outlive the copies
# that still need its user_id mapping. A retry resumes here.
break
# Update final status
if all_succeeded:
request.status = DeletionStatus.AWAITING_VERIFICATION
else:
request.status = DeletionStatus.PARTIALLY_COMPLETED
await self._save_request(request)
# Publish completion event
await self.events.publish("deletion", {
"type": "deletion.executed",
"request_id": request_id,
"status": request.status.value,
"all_succeeded": all_succeeded
})
return request
async def _execute_target(
self,
request: DeletionRequest,
target: DeletionTarget
):
"""Execute deletion for a single target system."""
target.status = SystemDeletionStatus.IN_PROGRESS
target.started_at = datetime.now(timezone.utc)
executor = self.executors.get(target.system_name)
if not executor:
logger.warning(f"No executor for system: {target.system_name}")
target.status = SystemDeletionStatus.SKIPPED
return
try:
result = await executor.delete_user_data(
user_id=request.user_id,
tenant_id=request.tenant_id
)
target.status = SystemDeletionStatus.COMPLETED
target.completed_at = datetime.now(timezone.utc)
target.records_deleted = result.get("records_deleted", 0)
logger.info(
f"Deletion completed for {target.system_name}",
extra={
"request_id": request.id,
"records_deleted": target.records_deleted
}
)
except Exception as e:
target.status = SystemDeletionStatus.FAILED
target.error_message = str(e)
target.completed_at = datetime.now(timezone.utc)
raise
async def verify_deletion(self, request_id: str) -> Dict[str, Any]:
"""
Verify that deletion was successful.
Checks each system to confirm data is gone.
"""
request = await self.get_request(request_id)
verification_results = {}
all_verified = True
for target in request.targets:
if target.status == SystemDeletionStatus.SKIPPED:
verification_results[target.system_name] = {
"status": "skipped",
"verified": True
}
continue
executor = self.executors.get(target.system_name)
if not executor:
verification_results[target.system_name] = {
"status": "no_executor",
"verified": False
}
all_verified = False
continue
try:
exists = await executor.check_user_exists(
user_id=request.user_id,
tenant_id=request.tenant_id
)
verification_results[target.system_name] = {
"status": "verified" if not exists else "data_found",
"verified": not exists,
"checked_at": datetime.now(timezone.utc).isoformat()
}
if exists:
all_verified = False
logger.warning(
f"Data still exists in {target.system_name}",
extra={"request_id": request_id}
)
except Exception as e:
verification_results[target.system_name] = {
"status": "error",
"verified": False,
"error": str(e)
}
all_verified = False
# Update request
request.verification_report = verification_results
request.verified_at = datetime.now(timezone.utc)
if all_verified:
request.status = DeletionStatus.COMPLETED
request.completed_at = datetime.now(timezone.utc)
await self._save_request(request)
# Notify user if complete
if all_verified:
await self._notify_completion(request)
return {
"all_verified": all_verified,
"results": verification_results
}
async def _determine_targets(
self,
user_id: str,
tenant_id: str
) -> List[DeletionTarget]:
"""Determine which systems have data for this user."""
targets = []
for target_template in self.DELETION_TARGETS:
# Check if system has data for this user
executor = self.executors.get(target_template.system_name)
if executor:
has_data = await executor.check_user_exists(user_id, tenant_id)
if has_data:
targets.append(DeletionTarget(
system_name=target_template.system_name,
system_type=target_template.system_type,
deletion_method=target_template.deletion_method,
priority=target_template.priority
))
return targets
async def _notify_completion(self, request: DeletionRequest):
"""Notify user that deletion is complete."""
await self.notifications.send(
user_id=request.user_id,
template="deletion_complete",
context={
"request_id": request.id,
"completed_at": request.completed_at.isoformat(),
"systems_deleted": len([
t for t in request.targets
if t.status == SystemDeletionStatus.COMPLETED
])
}
)
async def get_request(self, request_id: str) -> DeletionRequest:
"""Get a deletion request by ID."""
row = await self.db.fetchrow(
"SELECT * FROM deletion_requests WHERE id = $1",
request_id
)
if not row:
raise ValueError(f"Deletion request not found: {request_id}")
return self._row_to_request(row)
async def _save_request(self, request: DeletionRequest):
"""Save deletion request to database."""
await self.db.execute(
"""
INSERT INTO deletion_requests
(id, user_id, tenant_id, requested_at, requested_by, reason,
status, targets, deadline, completed_at, verified_at, verification_report)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (id) DO UPDATE SET
status = $7, targets = $8, completed_at = $10,
verified_at = $11, verification_report = $12
""",
request.id, request.user_id, request.tenant_id,
request.requested_at, request.requested_by, request.reason,
request.status.value,
[t.__dict__ for t in request.targets],
request.deadline, request.completed_at, request.verified_at,
request.verification_report
)
def _row_to_request(self, row) -> DeletionRequest:
"""Convert database row to DeletionRequest."""
return DeletionRequest(
id=row["id"],
user_id=row["user_id"],
tenant_id=row["tenant_id"],
requested_at=row["requested_at"],
requested_by=row["requested_by"],
reason=row["reason"],
status=DeletionStatus(row["status"]),
targets=[DeletionTarget(**t) for t in row["targets"]],
deadline=row["deadline"],
completed_at=row.get("completed_at"),
verified_at=row.get("verified_at"),
verification_report=row.get("verification_report")
)An erasure request starts PENDING with a 30-day deadline. Targets run in priority order, in parallel within a priority: search, files, analytics, third parties, then PostgreSQL, then caches last. A failure (Stripe timing out) stops later groups and leaves the request PARTIALLY_COMPLETED for a retry; verification checks every system before COMPLETED.
4.2 System-Specific Deletion Executors#
# deletion/executors.py
"""
System-specific deletion executors.
Each executor knows how to delete user data from a specific system.
"""
from abc import ABC, abstractmethod
from typing import Dict, Any
from elasticsearch import NotFoundError
from google.cloud import bigquery
import logging
logger = logging.getLogger(__name__)
class DeletionExecutor(ABC):
"""Base class for deletion executors."""
@abstractmethod
async def delete_user_data(
self,
user_id: str,
tenant_id: str
) -> Dict[str, Any]:
"""
Delete all user data from this system.
Returns dict with deletion details.
"""
pass
@abstractmethod
async def check_user_exists(
self,
user_id: str,
tenant_id: str
) -> bool:
"""Check if user data exists in this system."""
pass
class PostgreSQLDeletionExecutor(DeletionExecutor):
"""
Deletes user data from PostgreSQL.
Handles cascading deletions across related tables.
"""
def __init__(self, db_pool):
self.db = db_pool
async def delete_user_data(
self,
user_id: str,
tenant_id: str
) -> Dict[str, Any]:
"""
Delete user and all related data.
Order matters due to foreign keys:
1. Delete from leaf tables first
2. Work up to parent tables
3. Finally delete user record
"""
records_deleted = 0
async with self.db.acquire() as conn:
async with conn.transaction():
# Delete from leaf tables first (no foreign key dependencies)
# Notifications
result = await conn.execute(
"DELETE FROM notifications WHERE user_id = $1 AND tenant_id = $2",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# Activity logs
result = await conn.execute(
"DELETE FROM activity_logs WHERE user_id = $1 AND tenant_id = $2",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# Sessions
result = await conn.execute(
"DELETE FROM sessions WHERE user_id = $1 AND tenant_id = $2",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# User preferences
result = await conn.execute(
"DELETE FROM user_preferences WHERE user_id = $1 AND tenant_id = $2",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# Consent records (keep anonymized version for audit)
result = await conn.execute(
"""
UPDATE consent_records
SET user_id = 'DELETED', ip_address = 'DELETED'
WHERE user_id = $1 AND tenant_id = $2
""",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# Orders - anonymize rather than delete (financial records)
result = await conn.execute(
"""
UPDATE orders
SET user_id = NULL,
shipping_address = 'DELETED',
billing_address = 'DELETED',
customer_email = 'DELETED',
customer_phone = 'DELETED'
WHERE user_id = $1 AND tenant_id = $2
""",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# Comments - anonymize to preserve content integrity
result = await conn.execute(
"""
UPDATE comments
SET user_id = NULL, author_name = 'Deleted User'
WHERE user_id = $1 AND tenant_id = $2
""",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# Finally, delete the user record
result = await conn.execute(
"DELETE FROM users WHERE id = $1 AND tenant_id = $2",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
logger.info(
f"PostgreSQL deletion complete",
extra={
"user_id": user_id,
"records_deleted": records_deleted
}
)
return {"records_deleted": records_deleted}
async def check_user_exists(
self,
user_id: str,
tenant_id: str
) -> bool:
"""Check if user exists in PostgreSQL."""
result = await self.db.fetchrow(
"SELECT 1 FROM users WHERE id = $1 AND tenant_id = $2",
user_id, tenant_id
)
return result is not None
class ElasticsearchDeletionExecutor(DeletionExecutor):
"""Deletes user data from Elasticsearch."""
def __init__(self, es_client):
self.es = es_client
async def delete_user_data(
self,
user_id: str,
tenant_id: str
) -> Dict[str, Any]:
"""Delete user documents from all indices."""
indices = ["users", "documents", "activities"]
total_deleted = 0
for index in indices:
try:
result = await self.es.delete_by_query(
index=f"{tenant_id}_{index}",
body={
"query": {
"term": {"user_id": user_id}
}
}
)
total_deleted += result.get("deleted", 0)
except NotFoundError:
continue # Index doesn't exist for this tenant: nothing to delete
# Any other error propagates, so the target is marked FAILED
return {"records_deleted": total_deleted}
async def check_user_exists(
self,
user_id: str,
tenant_id: str
) -> bool:
"""Check if user exists in any index."""
indices = ["users", "documents", "activities"]
for index in indices:
try:
result = await self.es.count(
index=f"{tenant_id}_{index}",
body={
"query": {
"term": {"user_id": user_id}
}
}
)
if result.get("count", 0) > 0:
return True
except NotFoundError:
continue
# Other errors propagate: "couldn't check" must never read as "deleted"
return False
class RedisDeletionExecutor(DeletionExecutor):
"""Deletes user data from Redis cache."""
def __init__(self, redis_client):
self.redis = redis_client
async def delete_user_data(
self,
user_id: str,
tenant_id: str
) -> Dict[str, Any]:
"""Delete all user cache keys."""
patterns = [
f"tenant:{tenant_id}:user:{user_id}:*",
f"tenant:{tenant_id}:session:{user_id}:*",
f"tenant:{tenant_id}:cache:user:{user_id}",
]
total_deleted = 0
for pattern in patterns:
# SCAN, not KEYS: KEYS is O(N) and blocks Redis while it runs
keys = [k async for k in self.redis.scan_iter(match=pattern)]
if keys:
deleted = await self.redis.delete(*keys)
total_deleted += deleted
return {"records_deleted": total_deleted}
async def check_user_exists(
self,
user_id: str,
tenant_id: str
) -> bool:
"""Check if user data exists in cache."""
patterns = [
f"tenant:{tenant_id}:user:{user_id}:*",
f"tenant:{tenant_id}:session:{user_id}:*",
]
for pattern in patterns:
async for _ in self.redis.scan_iter(match=pattern):
return True
return False
class S3DeletionExecutor(DeletionExecutor):
"""Deletes user files from S3."""
def __init__(self, s3_client, bucket: str):
self.s3 = s3_client
self.bucket = bucket
async def delete_user_data(
self,
user_id: str,
tenant_id: str
) -> Dict[str, Any]:
"""Delete all user files from S3."""
prefix = f"tenants/{tenant_id}/users/{user_id}/"
# List all objects with prefix
objects_to_delete = []
paginator = self.s3.get_paginator('list_objects_v2')
async for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
for obj in page.get('Contents', []):
objects_to_delete.append({'Key': obj['Key']})
if not objects_to_delete:
return {"records_deleted": 0}
# Delete in batches of 1000 (S3 limit). On a versioned bucket this
# only adds delete markers: old versions must expire via a lifecycle
# rule (see s3_versioning retention below) or be deleted by VersionId
total_deleted = 0
for i in range(0, len(objects_to_delete), 1000):
batch = objects_to_delete[i:i+1000]
result = await self.s3.delete_objects(
Bucket=self.bucket,
Delete={'Objects': batch}
)
if result.get('Errors'):
raise RuntimeError(f"S3 delete errors: {result['Errors'][:3]}")
total_deleted += len(batch)
return {"records_deleted": total_deleted}
async def check_user_exists(
self,
user_id: str,
tenant_id: str
) -> bool:
"""Check if user has files in S3."""
prefix = f"tenants/{tenant_id}/users/{user_id}/"
result = await self.s3.list_objects_v2(
Bucket=self.bucket,
Prefix=prefix,
MaxKeys=1
)
return result.get('KeyCount', 0) > 0
class StripeDeletionExecutor(DeletionExecutor):
"""Deletes user data from Stripe."""
def __init__(self, stripe_client):
self.stripe = stripe_client
async def delete_user_data(
self,
user_id: str,
tenant_id: str
) -> Dict[str, Any]:
"""Delete customer from Stripe."""
# Find Stripe customer by metadata (list() can't filter on metadata;
# the Search API can). Storing stripe_customer_id locally is better.
customers = await self.stripe.Customer.search(
query=f"metadata['user_id']:'{user_id}' AND metadata['tenant_id']:'{tenant_id}'",
limit=1
)
if not customers.data:
return {"records_deleted": 0}
customer = customers.data[0]
# Deleting the customer cancels its subscriptions; Stripe may retain
# payment records it is legally required to keep
await self.stripe.Customer.delete(customer.id)
return {"records_deleted": 1, "stripe_customer_id": customer.id}
async def check_user_exists(
self,
user_id: str,
tenant_id: str
) -> bool:
"""Check if customer exists in Stripe."""
customers = await self.stripe.Customer.search(
query=f"metadata['user_id']:'{user_id}' AND metadata['tenant_id']:'{tenant_id}'",
limit=1
)
return len(customers.data) > 0
class BigQueryDeletionExecutor(DeletionExecutor):
"""Deletes user data from BigQuery analytics."""
def __init__(self, bq_client, dataset: str):
self.bq = bq_client
self.dataset = dataset
async def delete_user_data(
self,
user_id: str,
tenant_id: str
) -> Dict[str, Any]:
"""
Delete user data from BigQuery tables.
Note: BigQuery DELETE can be slow and expensive.
Consider partitioning strategy for better deletion.
"""
tables = ["events", "page_views", "user_properties"]
total_deleted = 0
for table in tables:
query = f"""
DELETE FROM `{self.dataset}.{table}`
WHERE user_id = @user_id AND tenant_id = @tenant_id
"""
job_config = bigquery.QueryJobConfig(
query_parameters=[
bigquery.ScalarQueryParameter("user_id", "STRING", user_id),
bigquery.ScalarQueryParameter("tenant_id", "STRING", tenant_id),
]
)
job = self.bq.query(query, job_config=job_config)
job.result() # Wait for the DML job to finish
total_deleted += job.num_dml_affected_rows or 0
return {"records_deleted": total_deleted}
async def check_user_exists(
self,
user_id: str,
tenant_id: str
) -> bool:
"""Check if user exists in BigQuery."""
query = f"""
SELECT 1 FROM `{self.dataset}.events`
WHERE user_id = @user_id AND tenant_id = @tenant_id
LIMIT 1
"""
job_config = bigquery.QueryJobConfig(
query_parameters=[
bigquery.ScalarQueryParameter("user_id", "STRING", user_id),
bigquery.ScalarQueryParameter("tenant_id", "STRING", tenant_id),
]
)
rows = self.bq.query(query, job_config=job_config).result()
return rows.total_rows > 0Deletion orchestration
Q1/3 In
DELETION_TARGETS, analytics runs at priority 4 and PostgreSQL at priority 10. Why is the primary database deleted last?Answer & explanation
Answer: C
The lesson's comments say analytics goes "before primary DB to capture user_id mapping" and the primary DB goes last because it has foreign keys. Speed is not the reason; same-priority targets already run in parallel.
Q2/3 During
execute_deletion, the Stripe call times out while every other target up to priority 5 succeeds. What state does the request end in?Answer & explanation
Answer: A
asyncio.gather(..., return_exceptions=True)collects the failure, marks that target FAILED, and setsall_succeeded = False, so the request becomes PARTIALLY_COMPLETED. The loop then stops, so PostgreSQL and the caches (priorities 10 and 11) stay pending until a retry resumes there. Nothing is rolled back across systems, and only a fully successful run reaches AWAITING_VERIFICATION.Q3/3 Why does the PostgreSQL executor UPDATE
orders(user_id to NULL, addresses to 'DELETED') instead of deleting them?Answer & explanation
Answer: D
Orders are financial records with legal retention, so the code anonymizes them rather than deleting. Re-linking would mean the data was never really de-identified, which defeats the point.
Estimate: when is the user really gone?
Primary systems are cleaned on day 2 after the request. Using the retention periods in the preface (app logs 15 days; daily database backups and MongoDB snapshots 30 days; weekly backups and access logs 90 days), on what day is the last copy gone? What does that mean for the confirmation email the user asked for?
One strong answer
- Logs: gone by about day 17. Daily backups and snapshots: by about day 32.
- Weekly backups and access logs: by about day 92. The longest retention, not the deletion job, sets the date.
- So the confirmation can truthfully say "deleted from live systems on day 2; backups fully purged by day 92", and any restore before then must replay the deletion.
Chapter 5: Handling Special Cases#
5.1 Backup Data Handling#
# deletion/backup_handler.py
"""
Handling user data in backups.
Backups are the hardest deletion challenge because you can't
surgically remove one user from a backup.
"""
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import List
import logging
logger = logging.getLogger(__name__)
@dataclass
class BackupRetentionPolicy:
"""Backup retention configuration."""
backup_type: str
retention_days: int
deletion_strategy: str # "expire" or "exclude_restore"
class BackupDeletionHandler:
"""
Handles deletion requests for data in backups.
Strategy:
1. Track users pending deletion
2. When backup expires, deletion is complete for that backup
3. If backup is restored, apply pending deletions
"""
RETENTION_POLICIES = [
BackupRetentionPolicy("postgresql_daily", 30, "expire"),
BackupRetentionPolicy("postgresql_weekly", 90, "expire"),
BackupRetentionPolicy("mongodb_daily", 30, "expire"),
BackupRetentionPolicy("s3_versioning", 90, "expire"),
]
def __init__(self, db):
self.db = db
async def register_pending_deletion(
self,
deletion_request_id: str,
user_id: str,
tenant_id: str
):
"""
Register a user for deletion from backups.
This tracks that when backups expire or are restored,
this user's data must be deleted.
"""
# Calculate when backup retention expires for each type
expiry_dates = {}
for policy in self.RETENTION_POLICIES:
expiry_date = datetime.now(timezone.utc) + timedelta(days=policy.retention_days)
expiry_dates[policy.backup_type] = expiry_date
await self.db.execute(
"""
INSERT INTO backup_pending_deletions
(deletion_request_id, user_id, tenant_id, created_at, backup_expiry_dates)
VALUES ($1, $2, $3, $4, $5)
""",
deletion_request_id, user_id, tenant_id,
datetime.now(timezone.utc), expiry_dates
)
logger.info(
f"Registered pending backup deletion",
extra={
"deletion_request_id": deletion_request_id,
"user_id": user_id,
"latest_expiry": max(expiry_dates.values())
}
)
async def get_backup_deletion_status(
self,
deletion_request_id: str
) -> dict:
"""
Get status of backup deletion for a request.
"""
record = await self.db.fetchrow(
"""
SELECT * FROM backup_pending_deletions
WHERE deletion_request_id = $1
""",
deletion_request_id
)
if not record:
return {"status": "not_tracked"}
expiry_dates = record["backup_expiry_dates"]
now = datetime.now(timezone.utc)
status = {}
all_expired = True
for backup_type, expiry_date in expiry_dates.items():
if expiry_date <= now:
status[backup_type] = "expired"
else:
status[backup_type] = f"expires_{expiry_date.isoformat()}"
all_expired = False
return {
"status": "complete" if all_expired else "pending",
"backup_status": status,
"fully_deleted_at": max(expiry_dates.values()) if not all_expired else None
}
async def on_backup_restore(
self,
backup_type: str,
backup_date: datetime,
restore_database: str
):
"""
Called when a backup is restored.
Must apply all pending deletions to the restored data.
"""
# Re-apply every deletion requested AFTER the backup was taken: the
# backup still contains those users. Deletes are idempotent, so also
# include requests from the 30 days before it, which may not have
# finished executing when the snapshot ran.
pending = await self.db.fetch(
"""
SELECT * FROM backup_pending_deletions
WHERE created_at >= $1
""",
backup_date - timedelta(days=30)
)
logger.warning(
f"Applying {len(pending)} pending deletions to restored backup",
extra={"backup_type": backup_type, "backup_date": backup_date}
)
for record in pending:
# Apply deletion to restored database
await self._apply_deletion_to_restore(
record["user_id"],
record["tenant_id"],
restore_database
)
async def _apply_deletion_to_restore(
self,
user_id: str,
tenant_id: str,
database: str
):
"""Apply a pending deletion to a restored database."""
# This would use the same logic as the main deletion
# but target the restored database
logger.info(
f"Applying deletion to restored database",
extra={
"user_id": user_id,
"database": database
}
)| Value | |
|---|---|
| Primary stores, search, cache, files, analytics, third parties (assumed done on day 2) | day 2, inside the one-month deadline |
| Application logs (Datadog, 15 days) | gone by ~day 17 |
| PostgreSQL daily backups, MongoDB snapshots (30 days) | gone by ~day 32 |
| PostgreSQL weekly backups, CloudWatch access logs (90 days) | gone by ~day 92 |
| S3 old object versions (90-day lifecycle rule, once the preface's "infinite" is fixed) | gone by ~day 92 |
| Deletion requests per month (assumed) | 1,000 |
Rows in backup_pending_deletions still inside the 90-day window |
1,000 × 3 months ≈ 3,000 |
Conclusion: the user is fully gone around day 92, three times the 30 days in their email, and the 90-day retentions set that date. Shortening weekly backups and access logs is what brings the promise forward. Until then, every restore must replay up to ~3,000 pending deletions before the data goes live.
5.2 Anonymization Service#
# deletion/anonymization.py
"""
Data anonymization for cases where deletion isn't possible.
Some data can't be deleted (legal retention) but can be anonymized.
"""
from typing import Dict, Any
import hashlib
import secrets
import uuid
class AnonymizationService:
"""
Anonymizes data instead of deleting it.
Used for:
- Financial records (legal retention requirements)
- Aggregated statistics
- Audit logs
Note: an unsalted hash of an ID is only pseudonymisation (anyone with
the ID can recompute it), so IDs are replaced randomly and hashes use
a per-run secret salt that is never stored.
"""
def __init__(self):
self._salt = secrets.token_bytes(16) # Discarded with the instance
def anonymize_user_record(self, user: Dict[str, Any]) -> Dict[str, Any]:
"""
Anonymize a user record.
Removes all PII while preserving structure.
"""
return {
"id": self._generate_anonymous_id(user["id"]),
"email": "deleted@anonymized.local",
"name": "Deleted User",
"phone": None,
"address": None,
"created_at": user["created_at"], # Keep for analytics
"tenant_id": user["tenant_id"], # Keep for tenant analytics
}
def anonymize_order(self, order: Dict[str, Any]) -> Dict[str, Any]:
"""
Anonymize an order record.
Keeps financial data but removes PII.
"""
return {
"id": order["id"],
"user_id": None, # Remove link to user
"tenant_id": order["tenant_id"],
"amount": order["amount"], # Keep for financial records
"currency": order["currency"],
"created_at": order["created_at"],
"shipping_address": "REDACTED",
"billing_address": "REDACTED",
"customer_email": "deleted@anonymized.local",
"customer_phone": None,
"items": order["items"], # Keep order details
}
def anonymize_event(self, event: Dict[str, Any]) -> Dict[str, Any]:
"""
Anonymize an analytics event.
"""
return {
"event_type": event["event_type"],
"tenant_id": event["tenant_id"],
"timestamp": event["timestamp"],
"user_id": None, # Remove user link
"session_id": self._hash_value(event.get("session_id", "")),
"properties": self._anonymize_properties(event.get("properties", {})),
}
def _generate_anonymous_id(self, original_id: str) -> str:
"""Generate an anonymous ID with no link to the original."""
# Random, not hash(original_id): a deterministic hash can be
# recomputed from the ID and re-linked
return uuid.uuid4().hex[:16]
def _hash_value(self, value: str) -> str:
"""Hash with the throwaway salt: groups equal values, can't be reversed."""
if not value:
return ""
return hashlib.sha256(self._salt + value.encode()).hexdigest()[:12]
def _anonymize_properties(self, props: Dict) -> Dict:
"""Remove PII from event properties."""
pii_keys = ["email", "name", "phone", "address", "ip_address", "user_agent"]
return {
k: "REDACTED" if k in pii_keys else v
for k, v in props.items()
}Backups and anonymization
Q1/2 Three weeks after a user was deleted, an incident forces you to restore a database backup taken before the deletion. What must happen?
Answer & explanation
Answer: B
The handler tracks users pending deletion precisely so that a restore can re-apply their deletions; otherwise the restore would resurrect erased data. Backups are not exempt, and blocking a recovery during an incident is not the lesson's approach.
Q2/2 An analytics event has a property
contactholding the user's email. What does_anonymize_propertiesdo with it?Answer & explanation
Answer: C
The function redacts only keys named in
pii_keys(email, name, phone, and so on), so PII under any other key passes through. Key-based redaction is only as good as the list, which is why a data inventory of where PII lives matters.
Chapter 6: Audit Trail and Compliance Reporting#
Trade-off: proof versus forgetting
An auditor will one day ask you to prove that this user was deleted. If you delete everything about them, you have no proof; if you keep a record, you've kept something about them. What do you keep, and what do you leave out?
One strong answer
- Keep a deletion audit trail: the request, each system's status, record counts, timestamps, who acted, and the verification result.
- Leave out the personal data: the report shows the user ID as redacted, and older audit entries have the ID replaced rather than deleted.
- Never delete the audit trail itself. Without it you can't show what happened, and a missing trail looks like a cover-up.
6.1 Deletion Audit Log#
# deletion/audit.py
"""
Audit logging for deletion requests.
Critical for proving compliance with GDPR.
"""
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional, List
from enum import Enum
import uuid
class AuditEventType(Enum):
"""Types of deletion audit events."""
REQUEST_CREATED = "request_created"
REQUEST_APPROVED = "request_approved"
DELETION_STARTED = "deletion_started"
SYSTEM_DELETION_STARTED = "system_deletion_started"
SYSTEM_DELETION_COMPLETED = "system_deletion_completed"
SYSTEM_DELETION_FAILED = "system_deletion_failed"
VERIFICATION_STARTED = "verification_started"
VERIFICATION_COMPLETED = "verification_completed"
REQUEST_COMPLETED = "request_completed"
REQUEST_FAILED = "request_failed"
@dataclass
class DeletionAuditEvent:
"""An audit event for a deletion request."""
id: str
deletion_request_id: str
event_type: AuditEventType
timestamp: datetime
actor: str # Who/what triggered this event
system_name: Optional[str]
details: dict
class DeletionAuditService:
"""
Records audit trail for all deletion activities.
This audit log is retained even after deletion completes
to prove compliance.
"""
def __init__(self, db):
self.db = db
async def log_event(
self,
deletion_request_id: str,
event_type: AuditEventType,
actor: str,
system_name: Optional[str] = None,
details: dict = None
) -> DeletionAuditEvent:
"""
Log an audit event.
"""
event = DeletionAuditEvent(
id=str(uuid.uuid4()),
deletion_request_id=deletion_request_id,
event_type=event_type,
timestamp=datetime.now(timezone.utc),
actor=actor,
system_name=system_name,
details=details or {}
)
await self.db.execute(
"""
INSERT INTO deletion_audit_log
(id, deletion_request_id, event_type, timestamp, actor, system_name, details)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
event.id, event.deletion_request_id, event.event_type.value,
event.timestamp, event.actor, event.system_name, event.details
)
return event
async def get_audit_trail(
self,
deletion_request_id: str
) -> List[DeletionAuditEvent]:
"""
Get complete audit trail for a deletion request.
"""
rows = await self.db.fetch(
"""
SELECT * FROM deletion_audit_log
WHERE deletion_request_id = $1
ORDER BY timestamp ASC
""",
deletion_request_id
)
return [
DeletionAuditEvent(
id=row["id"],
deletion_request_id=row["deletion_request_id"],
event_type=AuditEventType(row["event_type"]),
timestamp=row["timestamp"],
actor=row["actor"],
system_name=row["system_name"],
details=row["details"]
)
for row in rows
]
async def generate_compliance_report(
self,
deletion_request_id: str
) -> dict:
"""
Generate a compliance report for a deletion request.
This can be provided to auditors or data protection authorities.
"""
# Get the request
request = await self.db.fetchrow(
"SELECT * FROM deletion_requests WHERE id = $1",
deletion_request_id
)
# Get audit trail
audit_trail = await self.get_audit_trail(deletion_request_id)
# Build report
report = {
"report_generated_at": datetime.now(timezone.utc).isoformat(),
"deletion_request": {
"id": request["id"],
"user_id": "REDACTED", # Don't include actual user ID
"requested_at": request["requested_at"].isoformat(),
"requested_by": request["requested_by"],
"reason": request["reason"],
"deadline": request["deadline"].isoformat(),
"status": request["status"],
"completed_at": request["completed_at"].isoformat() if request["completed_at"] else None,
},
"systems_affected": [
{
"system": t["system_name"],
"status": t["status"],
"records_deleted": t.get("records_deleted", 0),
"completed_at": t.get("completed_at")
}
for t in request["targets"]
],
"audit_trail": [
{
"timestamp": e.timestamp.isoformat(),
"event": e.event_type.value,
"system": e.system_name,
"actor": e.actor
}
for e in audit_trail
],
"verification": request.get("verification_report", {}),
"compliance_statement": self._generate_compliance_statement(request)
}
return report
def _generate_compliance_statement(self, request: dict) -> str:
"""Generate a compliance statement for the report."""
if request["status"] == "completed":
return (
f"This deletion request was completed on {request['completed_at']}. "
f"All personal data for the data subject has been deleted from primary systems. "
f"Data in backups will be fully purged according to our retention policy."
)
elif request["status"] == "partially_completed":
return (
"This deletion request has been partially completed. "
"Some systems reported errors during deletion. "
"Manual intervention may be required."
)
else:
return f"This deletion request is currently in status: {request['status']}."Audit trail
Q1/2 An auditor asks you to prove user X was deleted, but the data is gone. How does the lesson resolve this paradox?
Answer & explanation
Answer: A
The deletion audit log is retained after deletion and the compliance report records systems, counts, timestamps and verification with
user_idredacted. Keeping a copy of the data would defeat the deletion, and deleting the audit trail leaves no proof at all.Q2/2 For a completed request, the compliance statement says primary systems are clean and backups "will be fully purged according to our retention policy". Why not simply say all copies are deleted?
Answer & explanation
Answer: D
Backups follow a "let expire" strategy tracked by retention dates, so at completion time copies still exist. The statement is accurate about that; backups are personal data and are not deleted immediately.
Part III: Real-World Application#
Chapter 7: Case Studies#
7.1 How Stripe Handles Deletion#
Challenge
- Massive data spread across many systems
- Financial data has legal retention requirements
- Payment data subject to PCI DSS
- Customers in multiple jurisdictions
Solution
Illustrative: Stripe does not publish its internal deletion pipeline or retention periods, so treat the specifics below as a plausible design, not a description.
1. DATA CATEGORIZATION
- Personal data: Name, email, address — Delete or anonymize on request
- Financial records: Transaction history — Anonymize, retain for legal requirements
- Payment instruments: Card numbers — Already tokenized, delete tokens
- Audit logs: Access records — Retain with anonymized references
2. DELETION WORKFLOW
- Self-serve deletion via Dashboard API
- Immediate deletion from primary stores
- Async deletion from analytics/logs
- Verification job confirms deletion
- Compliance report generated
3. RETENTION POLICY
- Personal data: Delete on request
- Transaction records: 7 years (tax/legal) — Anonymized after customer deletion
- Logs: 90 days standard
- Backups: 30 day retention
Lessons
- Distinguish delete vs anonymize vs retain
- Self-serve deletion reduces support burden
- Clear retention policies simplify compliance
- Verification step catches edge cases
7.2 How Slack Handles Deletion#
Challenge
- Messages involve multiple users
- Files shared across conversations
- Search indexes contain message content
- Enterprise customers need audit retention
Solution
1. USER DELETION
- Deactivate the account first
- The workspace's Primary Owner (the controller) decides on and performs profile deletion
- Messages (channels and DMs): Kept, author shown as "@deactivateduser"
- Files and canvases they created: Kept
- Profile: Name, email, photo, phone, title and custom fields deleted
2. MESSAGE DELETION
- User can delete own messages
- Admins can delete any message
- Files in messages: Separate deletion
- Search index updated async
3. WORKSPACE DELETION (Data Export + Delete)
- Export all data first (GDPR portability)
- Grace period during which admins can still restore it
- Then hard delete everything
- Cannot be recovered
4. ENTERPRISE COMPLIANCE MODE
- Org can require message retention
- Retention/legal holds preserve messages even if users edit or delete them
- After retention: Normal deletion rules
- Legal hold can prevent all deletion
Lessons
- Collaborative content needs special handling
- Showing a placeholder author ("@deactivateduser") preserves context
- Enterprise compliance may override user rights
- Grace period prevents accidental deletion
Chapter 8: Common Mistakes#
8.1 Deletion Anti-Patterns#
Mistake 1: Soft Delete Only
Wrong:
async def delete_user(user_id):
await db.execute(
"UPDATE users SET deleted = true WHERE id = $1",
user_id
)
# Done! User is "deleted"Problem: Data still exists, not GDPR compliant. Can be queried by mistake. Backups still contain data.
Right:
async def delete_user(user_id):
# Soft delete first (for grace period)
await db.execute(
"UPDATE users SET deleted = true, deleted_at = NOW() WHERE id = $1",
user_id
)
# Schedule the hard delete; the job runs the real DELETE in every
# system when the grace period ends. Keep grace + deletion time well
# inside the one-month GDPR deadline.
await schedule_hard_delete(user_id, delay_days=14)Mistake 2: Forgetting Foreign Keys
Wrong:
async def delete_user(user_id):
await db.execute("DELETE FROM users WHERE id = $1", user_id)
# Fails with foreign key violation!Problem: Orders, comments, etc. reference user_id. Deletion fails or orphans records.
Right:
async def delete_user(user_id):
async with db.transaction():
# Delete leaf records first
await db.execute("DELETE FROM notifications WHERE user_id = $1", user_id)
await db.execute("DELETE FROM sessions WHERE user_id = $1", user_id)
# Anonymize records we must keep
await db.execute(
"UPDATE orders SET user_id = NULL WHERE user_id = $1",
user_id
)
# Finally delete user
await db.execute("DELETE FROM users WHERE id = $1", user_id)Mistake 3: Not Deleting from Analytics
Wrong:
async def delete_user(user_id):
await db.execute("DELETE FROM users WHERE id = $1", user_id)
# Forgot BigQuery, Mixpanel, Amplitude...Problem: User data still in analytics systems. Can be queried and linked back. Violates deletion request.
Right:
async def delete_user(user_id):
# Primary DB
await db.execute("DELETE FROM users WHERE id = $1", user_id)
# Analytics - all of them
await bigquery.delete_user(user_id)
await mixpanel.delete_user(user_id)
await amplitude.delete_user(user_id)
# Third parties
await stripe.delete_customer(user_id)
await intercom.delete_user(user_id)Mistake 4: No Verification
Wrong:
async def delete_user(user_id):
for system in systems:
await system.delete(user_id)
return {"status": "deleted"} # Trust it worked!Problem: Deletion might have failed silently. No proof of deletion for auditors. Can't answer "was user X deleted?"
Right:
async def delete_user(user_id):
for system in systems:
await system.delete(user_id)
# Verify deletion
for system in systems:
exists = await system.check_exists(user_id)
if exists:
raise DeletionVerificationError(f"Data still in {system}")
# Log completion
await audit_log.record_deletion_complete(user_id)
return {"status": "verified_deleted"}Mistake 5: Deleting Audit Logs
Wrong:
async def delete_user(user_id):
# Delete everything including audit trail
await db.execute("DELETE FROM audit_logs WHERE user_id = $1", user_id)
await db.execute("DELETE FROM users WHERE id = $1", user_id)Problem: Can't prove what happened to the data. Compliance audit will fail. Suspicious - looks like cover-up.
Right:
async def delete_user(user_id):
# Anonymize audit logs, don't delete
await db.execute(
"UPDATE audit_logs SET user_id = 'DELETED' WHERE user_id = $1",
user_id
)
# Add deletion record to audit log
await db.execute(
"INSERT INTO audit_logs (action, user_id, timestamp) VALUES ($1, $2, $3)",
"USER_DELETED", "DELETED", datetime.now()
)
await db.execute("DELETE FROM users WHERE id = $1", user_id)Case studies and mistakes
Q1/3 When a Slack user's profile is deleted, their channel messages stay but are attributed to "@deactivateduser". Why not delete the messages outright?
Answer & explanation
Answer: B
Messages involve other users, so Slack keeps them for context while deleting the profile's personal details. The search index is updated asynchronously rather than being impossible to change, and the grace period applies to workspace deletion.
Q2/3 An enterprise customer has a legal hold on its workspace, and an employee requests erasure of their messages. What happens?
Answer & explanation
Answer: C
Article 17 lets you refuse erasure for the establishment, exercise or defence of legal claims, and Slack's compliance mode lets a legal hold prevent all deletion. Enterprise compliance can override the individual request here.
Q3/3 Your deletion job returned "deleted", yet a month later the user still shows up in search. Which practice from the lesson would have caught this?
Answer & explanation
Answer: D
Deletions can fail silently, so the lesson re-checks every system (
check_user_exists) before marking the request complete and logs the result. A database transaction cannot cover external systems like a search index, and deleting audit logs removes your proof.
Part IV: Interview Preparation#
Chapter 9: Interview Tips#
9.1 Deletion Discussion Framework#
- ACKNOWLEDGE THE COMPLEXITY — "Deletion sounds simple but is actually one of the hardest compliance challenges. Data is spread across many systems, and you need to prove it's actually gone."
- LIST THE CHALLENGES — "The main challenges are:"
- Data fragmentation across systems
- Foreign key relationships
- Third-party processors
- Backup retention
- Proving deletion happened
- PROPOSE A SYSTEMATIC APPROACH — "I'd implement:"
- A data inventory mapping all PII locations
- A deletion orchestration service
- System-specific executors for each data store
- Verification that confirms deletion
- Audit trail that survives the deletion
- ADDRESS SPECIAL CASES — "Some data can't be deleted:"
- Financial records: Anonymize instead
- Audit logs: Keep with anonymized references
- Backups: Let expire per retention policy
- Shared content: Show 'Deleted User'
- MENTION COMPLIANCE — "GDPR gives one month to respond. I'd implement:"
- Dashboard for users to request deletion
- Automated workflow with manual review option
- Progress tracking visible to user
- Compliance report generation for auditors
9.2 Key Phrases#
On Architecture
"I'd build a deletion orchestration service that coordinates deletion across all systems. Each system has an executor that knows how to delete data and verify it's gone. The orchestrator tracks progress and handles failures."
On Data Mapping
"The first step is a data inventory - mapping every place personal data lives. Without knowing where data is, you can't delete it. This includes primary databases, caches, search indexes, analytics, third-party services, and backups."
On Verification
"Deletion without verification is incomplete. After deletion, the system queries each data store to confirm the user's data is actually gone. Only then is the deletion marked complete."
On Backups
"Backups are the tricky part. You can't surgically remove one user from a backup. The solution is clear retention policies - backups expire after 30-90 days. Until then, if a backup is restored, we apply pending deletions to the restored data."
On Audit Trail
"The paradox of deletion is you need to prove it happened. I'd keep an anonymized audit trail - recording that 'user DELETED-12345 was deleted on date X' without the actual PII. This satisfies auditors while respecting the deletion."
Chapter 10: Practice Problems#
Practice · problem 1
Problem 1: Social Media Platform#
Scenario: Your social platform has users with posts, comments, likes, and followers. A user requests deletion under GDPR.
Questions:
- What happens to their posts that others have commented on?
- What about posts they're mentioned in?
- How do you handle their follower relationships?
Click for hints
- Posts they authored: Delete or show "Deleted User"
- Comments on others' posts: Anonymize ("Deleted User said...")
- Mentions: Replace @username with @deleted
- Followers: Delete both sides of relationship
- Consider what happens to replies to their posts
Practice · problem 2
Problem 2: E-commerce With Legal Retention#
Scenario: Your e-commerce platform must keep transaction records for 7 years for tax purposes. A user requests deletion after making purchases.
Questions:
- How do you handle the conflict between deletion and retention?
- What data can you delete, and what must you keep?
- How do you explain this to the user?
Click for hints
- Account data: Delete (name, email, preferences)
- Transaction records: Anonymize but retain
- Shipping addresses not needed for tax: delete; invoice fields the tax law requires (e.g. buyer name and billing address): keep, access-restricted
- Explain: "Account deleted. Financial records anonymized per legal requirements."
- Document the legal basis for retention
Chapter 11: Sample Interview Dialogue#
"A user requests deletion of all their data. Walk me through how you'd handle it."
"This is a GDPR Article 17 request. Let me walk through the systematic approach.
First, I need to know where data lives. I'd maintain a data inventory mapping every system with personal data. For a typical SaaS, that's:
- Primary database (user profile, content)
- Search index (user in search results)
- Cache (session, preferences)
- File storage (uploads)
- Analytics (behavioral data)
- Third-party services (payment, support)
The deletion flow would be:"
%% title: Account deletion flow %% steps: %% U --> C: The request is recorded, a 30-day timer starts and the user is told the timeline %% C --> E: Deletion runs in order: search index, file storage, analytics, third parties, the primary DB, then the caches %% E --> V: Each system is queried to confirm the data is gone; failures are flagged for review %% V --> D: The audit log is updated, a confirmation email goes out and a compliance certificate is generated flowchart TD U(["User clicks #quot;Delete Account#quot;"]):::client --> C["Create Deletion Request<br/>· Record request details<br/>· Start 30-day timer<br/>· Notify user of timeline"]:::core C --> E["Execute Deletion<br/>· Search index first<br/>· Then file storage<br/>· Then analytics<br/>· Then third parties<br/>· Then primary DB<br/>· Finally purge caches"]:::hot E --> V["Verify Deletion<br/>· Query each system<br/>· Confirm data is gone<br/>· Flag failures for review"]:::service V --> D(["Complete and Notify<br/>· Update audit log<br/>· Send confirmation email<br/>· Generate compliance cert"]):::ok
- The request is recorded, a 30-day timer starts and the user is told the timeline
- Deletion runs in order: search index, file storage, analytics, third parties, the primary DB, then the caches
- Each system is queried to confirm the data is gone; failures are flagged for review
- The audit log is updated, a confirmation email goes out and a compliance certificate is generated
"What about their order history? We need that for financial records."
"Good point - there's a conflict between deletion right and legal retention. Here's how I'd handle it:
The user record gets deleted - name, email, preferences. But the order records get anonymized rather than deleted. The order stays with user_id = NULL and addresses redacted as 'DELETED'.
The legal basis for retention is Article 17(3)(b) - 'compliance with a legal obligation.' We'd document this and explain to the user: 'Your account has been deleted. Transaction records are retained in anonymized form as required by tax law.'
(If the tax law requires invoices to carry the buyer's name and address, those fields are kept too, access-restricted, under the same exemption; that part is retention, not anonymization.)
This way we satisfy both the deletion request and the 7-year tax retention requirement."
"How do you handle backups?"
"Backups are the hardest part because you can't surgically remove one user.
My approach is a clear retention policy. Say daily backups are kept 30 days and weekly backups 90 days, as in our data map. Primary systems are clean by day 2, so the weekly backups set the date. We track pending deletions, and the compliance report notes: 'Primary systems deleted on Day 2. Backup data will be fully purged by Day 92.'
If we need to restore from backup before Day 92, we apply all pending deletions to the restored data before it goes live. This is why tracking pending deletions is critical. Shortening the weekly retention is what brings that date forward."
Summary#
Deletion requirements (GDPR)
- Respond within one month (extendable by two for complex cases)
- Delete from all systems
- Inform third parties
- Exceptions: Legal obligations, public interest
- Must be able to prove deletion
Deletion strategies
- Hard delete: Physically remove
- Soft delete: Mark as deleted (not sufficient alone)
- Anonymize: Remove identifying info, keep record
- Aggregate: Combine to remove individual
- Expire: Let retention policy handle
Data inventory
- Map all PII locations
- Document deletion method per system
- Track dependencies (foreign keys)
- Include third parties and backups
- Update when adding new systems
Deletion architecture
- Orchestration service coordinates
- System executors handle specifics
- Order matters (dependencies)
- Verification confirms success
- Audit trail proves compliance
Special cases
- Backups: Let expire, track pending
- Financial records: Anonymize, retain
- Shared content: Show "Deleted User"
- Audit logs: Anonymize, don't delete
- Third parties: API calls or manual
Verification
- Query each system after deletion
- Confirm no data returned
- Flag failures for manual review
- Generate compliance report
- Keep proof of deletion
Default approach
- Build data inventory first
- Implement executor per system
- Orchestrate with dependency order
- Always verify
- Keep anonymized audit trail
Further Reading#
Official Resources:
- Blog
GDPR Article 17 (official text): https://eur-lex.europa.eu/eli/reg/2016/679/oj
- Read
ICO Guidance on Erasure: https://ico.org.uk/right-of-erasure/
Vendor Documentation:
- Docs
Slack Help Center: "Delete profile information from Slack"
Tools:
- Read
OneTrust (privacy management)
- Read
BigID (data discovery)
- Read
Transcend (data deletion automation)
this lesson in shorts · 3
play them all →One idea from this lesson in about a minute each, made for your phone.
Go deeper · reading trail
all reading →Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.
DELF: Safeguarding deletion correctness in Online Social Networks
Katriel Cohn-Gordon, Georgios Damaskinos et al. · 2020- What
- Facebook's deletion framework, described in a USENIX Security paper.
- Key idea
- Developers annotate how data types relate, DELF cascades deletions along those edges across stores, and checks catch data that would be orphaned or wrongly deleted.
- Why here
- The data inventory and cascading deletion chapters of this lesson at social-network scale.
Data deletion on Google Cloud
- What
- Google's description of what happens when customer data is deleted.
- Key idea
- Deletion moves through stages: a request, soft deletion with a recovery period (up to 30 days in some cases), logical deletion from active systems, then expiry from backups; cryptographic erasure also applies.
- Why here
- The staged deletion service of this lesson, including the backup problem: you delete from backups by letting them expire.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.


