Introduction
Data pipelines rarely exist in isolation—they ingest customer records, financial transactions, health information, and behavioral data that often contains personally identifiable information (PII). Handling PII in pipelines requires deliberate architecture decisions that balance compliance, utility, and operational complexity. In this article, we'll explore practical approaches to protecting personal data while maintaining analytics capability, examining the trade-offs that shape real-world data engineering.
We'll focus on handling PII in pipelines through four complementary strategies: classifying what's sensitive, transforming identifiers early, controlling access to raw layers, and building infrastructure for deletion requests. Each approach comes with costs—latency, storage overhead, query complexity—that we need to understand before committing to production.
Classify sensitive columns early and systematically
The foundation of any PII strategy is knowing what data you have. Without classification, you're guessing which fields need protection, which leads to either over-protection (slow pipelines) or under-protection (compliance violations). We recommend establishing a data catalog that marks columns by sensitivity tier: public, internal, confidential, and restricted.
In practice, this means maintaining a metadata layer—a simple Parquet file or database table that maps table names and column names to sensitivity labels. When new schemas arrive, someone needs to review them and add entries to this catalog. This is lightweight but non-negotiable.
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, when
spark = SparkSession.builder.appName("pii-classification").getOrCreate()
# Load classification metadata
classification = spark.read.json("s3://data-catalog/pii-classification.json")
# Schema: {"table": "customers", "column": "email", "tier": "restricted"}
# Tiers: public, internal, confidential, restricted
def classify_dataframe(df, table_name, classification_df):
"""
Mark columns with PII tier using a UDF-backed approach.
In practice, store this mapping in your data catalog system.
"""
classified_cols = classification_df.filter(col("table") == table_name) \
.select("column", "tier").collect()
col_map = {row["column"]: row["tier"] for row in classified_cols}
return df, col_map
# Example
customers = spark.read.parquet("s3://raw/customers")
df, sensitivity_map = classify_dataframe(customers, "customers", classification)
print("Sensitive columns in 'customers' table:")
for col_name, tier in sensitivity_map.items():
if tier != "public":
print(f" {col_name}: {tier}")Once you know what's sensitive, you can apply controls downstream. Without this classification layer, you're flying blind. Yes, maintaining a catalog requires discipline, but it's far cheaper than discovering a GDPR violation in production.
Hash or tokenize identifiers early in the pipeline
Raw PII should not live in your pipeline longer than necessary. A common approach is to hash or tokenize sensitive identifiers—email, phone, social security number—as soon as they enter the raw layer. This way, downstream systems work with deterministic tokens rather than plaintext secrets.
There's a critical trade-off here: hashing is one-way (you lose the original value), while tokenization maintains a reversible mapping (you need a secure token store). For analytics, hashing often suffices. For customer service or marketing, you may need tokenization to look up the original value later.
from pyspark.sql.functions import sha2, col, concat_ws, lit
from pyspark.sql import DataFrame
def hash_pii_columns(df: DataFrame, hash_map: dict) -> DataFrame:
"""
Hash specified columns using SHA-256.
hash_map = {"email": "email_hash", "phone": "phone_hash"}
"""
result = df
for source_col, target_col in hash_map.items():
if source_col in df.columns:
result = result.withColumn(
target_col,
sha2(concat_ws("||", col(source_col), lit("salt_value")), 256)
)
return result
# Example
customers = spark.read.parquet("s3://raw/customers")
hashed = hash_pii_columns(
customers,
{"email": "email_hash", "phone": "phone_hash", "ssn": "ssn_hash"}
)
# Write hashed version to processed layer
hashed.write.mode("overwrite").parquet("s3://processed/customers")The salt value matters for security—use a strong, fixed salt (stored securely, not in code). The deterministic nature of hashing means the same email always produces the same hash, which is useful for deduplication and joins, but it also means hashes can be reverse-engineered if your salt leaks. For truly sensitive identifiers (SSN, credit card), consider tokenization instead, where you store the mapping in a separate, highly-secured service.
One pitfall: if you need to preserve raw PII for some workflows (churn modeling, customer communication), you can't hash it away entirely. In that case, segregate raw data into a separate, access-controlled dataset and apply hashing only to shared analytical tables.
Restrict raw layer access with principle of least privilege
Even with hashing, raw data layers often contain PII for operational reasons. A customer service system might need to look up an account holder's phone number; a fraud detection model might need transaction details tied to individuals. The answer is access control, not data deletion.
In practice, this means:
- Raw layers live in a private S3 bucket with restricted IAM policies
- Processed, hashed layers are in a shared bucket with broader read access
- Access to raw data is audited and time-limited (e.g., 90 days, then deletion)
- Different user cohorts (engineers, analysts, business users) have different dataset visibility
At the Spark job level, this translates to role-based filtering. A Spark job running as a service account can read raw data, hash it, and write to a shared layer. Analysts connect to that shared layer only, never seeing raw PII.
from pyspark.sql.functions import col, when
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("access-control-demo").getOrCreate()
def read_with_access_control(table_name: str, user_role: str):
"""
Simulate access control by filtering columns based on role.
In practice, use Ranger, Sentry, or your cloud provider's IAM policies.
"""
df = spark.read.parquet(f"s3://raw/{table_name}")
# Analysts can only see hashed data
if user_role == "analyst":
# Remove raw PII columns
return df.select([c for c in df.columns if not c.endswith("_raw")])
# Engineers with "data-ops" role can see everything
elif user_role == "data-ops":
return df
# Default: deny
else:
raise PermissionError(f"User role '{user_role}' not authorized")
# Example
customers = read_with_access_control("customers", user_role="analyst")
customers.show()At scale, you'll use your cloud provider's native tools. AWS uses Lakeformation and S3 bucket policies; Azure uses Role-Based Access Control (RBAC) on Data Lake Storage; GCP uses Identity and Access Management (IAM) on Cloud Storage. Spark jobs should assume the minimum role required for their task—a hashing job needs read on raw buckets and write on processed buckets, nothing more.
Support deletion requests and track data lineage
GDPR, CCPA, and other regulations require that you can delete a person's data on request. This is harder than it sounds in data pipelines because data flows through multiple stages: raw → processed → aggregated → archived. A single customer record might exist in 50 different tables and backups.
The practical approach is to maintain a deletion log and apply it during read operations (logical deletion) rather than physically deleting data immediately. This is cheaper than scanning and modifying every dataset, and it preserves data lineage for auditing.
from pyspark.sql.functions import col, when, broadcast
from datetime import datetime
import uuid
spark = SparkSession.builder.appName("deletion-support").getOrCreate()
def record_deletion_request(customer_id: str, reason: str = "GDPR"):
"""
Log deletion requests to a centralized table.
In production, write this to a durable, append-only log.
"""
deletion_record = spark.createDataFrame([
(str(uuid.uuid4()), customer_id, datetime.now(), reason)
], schema="deletion_id STRING, customer_id STRING, deleted_at TIMESTAMP, reason STRING")
deletion_record.write.mode("append").parquet("s3://compliance/deletion-log")
return deletion_record
def apply_deletion_filter(df, customer_id_col="customer_id"):
"""
Filter out rows for deleted customers.
"""
deletion_log = spark.read.parquet("s3://compliance/deletion-log")
deleted_customers = deletion_log.select(col("customer_id")).distinct()
result = df.join(
broadcast(deleted_customers),
df[customer_id_col] == deleted_customers["customer_id"],
how="left_anti" # Keep rows not in deletion log
)
return result
# Example workflow
customers = spark.read.parquet("s3://processed/customers")
# Simulate deletion request
record_deletion_request("cust_12345", "GDPR")
# Subsequent reads automatically exclude deleted customers
cleaned = apply_deletion_filter(customers, customer_id_col="id")
print(f"Original: {customers.count()}, After deletion filter: {cleaned.count()}")The deletion log becomes your source of truth for compliance. Keep it immutable and audited. When a request arrives, add an entry; downstream pipelines check this log on every read. This is slower than pre-computed deletes but far safer—you have a complete audit trail of who was deleted, when, and why.
For archived data (backups, data lakes older than 1 year), you have two options: either apply the deletion filter retroactively (expensive) or accept that old backups retain deleted data and communicate this to legal/compliance (they may accept it if backups are sufficiently isolated). Most regulations allow this trade-off if you document it.
Common mistakes
- Storing salt or encryption keys in code: If your repository is compromised, your hashes are reversible. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault) and rotate keys regularly.
- Hashing without a salt: Unsalted hashes are vulnerable to rainbow table attacks. Always use a strong, unique salt. Even better, use a slow hash function like bcrypt for sensitive data, though this is expensive at Spark scale.
- Assuming one layer of hashing is enough: If you have both raw and hashed versions of the same data in different tables, you haven't actually protected anything. Consolidate on hashed data for analytics and segregate raw data strictly.
- Forgetting backups and archives: You hash your live pipeline but forget that S3 versioning and RTO backups retain raw data. Deletion policies must cover backups too, or at least set expiration policies.
- Not auditing access: Access control is only useful if you log who accessed what and when. Enable CloudTrail, S3 access logging, or equivalent; audit quarterly.
- Tokenizing without a secure store: If your token-to-PII mapping is stored in the same data lake as the hashed data, you've lost security. Token stores need separate, hardened infrastructure with strict access controls.
Key takeaways
- Classify PII columns early using a data catalog; without knowing what's sensitive, you can't protect it effectively.
- Hash or tokenize identifiers in the raw layer before they're distributed to downstream systems; this limits exposure and simplifies access control.
- Segregate raw data behind strict IAM policies; analysts should work with hashed, non-sensitive datasets by default.
- Implement logical deletion via an append-only deletion log rather than physical deletion; it's cheaper, safer, and provides audit trails.
- Remember that PII handling is not just a technical problem—it requires coordination with legal, compliance, and security teams to define acceptable risks and retention policies.
- Test your deletion and access control workflows regularly; a policy that sounds good in theory may fail under load or after schema changes.






Comments
Loading comments…