8 Data Governance Tests Every Data Engineer Should Know
- Bill Donofrio

- Aug 16
- 9 min read
Introduction
Bad data doesn't announce itself. A NULL in a few customer_id fields, a duplicate order number, or a column setup as a string when it should be an integer can all be painful. By the time someone notices, it's already downstream in a dashboard or a financial report. The executives are in panic mode, and you and your team are sweating bullets. However, with the strategies listed below, most of these problems can be prevented.
This article walks through eight test patterns that every data engineer should have in their toolkit. Just by copying these simple code snippets, you can start going down the path of better data governance. Each section shows a simple SQL script to understand the concept. It then provides a Python function you can add to your current pipelines. This quick read will surely save you hours of frustration.
1. Completeness: Check for NULLs in Your Tables
Checking for missing values is as simple as running the query below. If your SQL developers didn't put NOT NULL constraints on your columns, this check will help. It's also useful when you're working with a lot of imported Excel files and want to ensure that all your customers have a phone number and email address.
SELECT COUNT(*) AS null_count
FROM orders
WHERE customer_id IS NULL
However, for a more automated approach, use the script below. Just pass in your table name and the script will find all the columns in the table. It will then loop through each one and give you a count of NULL values by column name. As an added bonus, you can pass a list of columns to exclude from the check. Since some columns are expected to have NULLs, you can exclude them from the check.
from pyspark.sql import SparkSession
from pyspark.sql.functions import col
spark = SparkSession.builder.appName("SparkSession").getOrCreate()
def test_completeness(table_name, exclude_column=[]):
df = spark.table(table_name)
results = []
for column in df.columns:
if column in exclude_column:
continue
nulls = df.filter(col(column).isNull()).count()
results.append((table_name, column, nulls))
df = spark.createDataFrame(results, ["table", "column", "null_count"])
return df
table_name = "finance"
df = test_completeness(table_name, ['primary_key', 'foreign_key'])
display(df)
2. Uniqueness: No Duplicate Keys
Here is another simple script to locate duplicate records for each column. Duplicate keys are by far the most frustrating problem in any data engineering project. One simple mistake can cause a many-to-many relationship and incorrect counts. Sadly, such duplicates can be less than 0.1% of the table volume, making them tough to find using a spot check.
SELECT order_id
, COUNT(*) AS total
FROM orders
GROUP BY order_id
HAVING COUNT(*) > 1
Now let's improve this to let a user provide a table and column names and automatically check the result. You'll notice this script is very similar to the one in the first section, but instead of looking for NULL values, we're comparing distinct counts to total counts.
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("SparkSession").getOrCreate()
def test_uniqueness(table_name, columns):
df = spark.table(table_name)
total_count = df.count()
results = []
for column in columns:
distinct_count = df.select(column).distinct().count()
dupes = total_count - distinct_count
results.append((table_name, column, dupes))
df = spark.createDataFrame(results, ["table", "column", "duplicate_count"])
return df
table_name = "finance"
df = test_uniqueness(table_name, ['foreign_key1', 'foreign_key2'])
display(df)
3. Referential Integrity: Ensure There Are No Orphaned Foreign Keys

This check is great for catching missing foreign keys. I've seen this issue often during testing. You refresh one table but forget to refresh a related table. This causes one table to end up with more keys than the other, or vice versa.
SELECT o.order_id
FROM orders o
LEFT OUTER JOIN customers c
ON o.customer_id = c.customer_id
WHERE c.customer_id IS NULL
The script below takes this a step further for a more automated process. Just supply two tables and the key that joins them. This script pulls all the keys into two lists, then compares them against each other to find keys that don't match. The final results are returned as a DataFrame.
from pyspark.sql import SparkSession
from pyspark.sql.functions import lit
spark = SparkSession.builder.appName("SparkSession").getOrCreate()
def test_referential_integrity(left_table, right_table, left_key, right_key=None):
left_df = spark.table(left_table)
right_df = spark.table(right_table)
# If no right_key is provided, assume it is the same as the left_key
if right_key is None:
right_key = left_key
# Normalize keys to lists so single or composite keys both work
left_key = [left_key] if isinstance(left_key, str) else left_key
right_key = [right_key] if isinstance(right_key, str) else right_key
join_condition = [
left_df[lk] == right_df[rk] for lk, rk in zip(left_key, right_key)
]
# Values in left_table with no match in right_table
orphans = (
left_df.join(right_df, on=join_condition, how="left_outer")
.filter(right_df[right_key[0]].isNull())
.select(left_df["*"])
.withColumn("missing_from", lit(right_table)) )
return orphans
left_table = "orders"
right_table = "customers"
left_key = "customer_id"
df = test_referential_integrity(left_table, right_table, left_key)
display(df)
4. Format Validity: Check for a Pattern Match
This check is extremely valuable when there's an expected pattern. For example, a phone number might be formatted (120) 234-3232 or 120.234.3232. An email address must include an "@" and a "." as shown in the example below. A Social Security number should be in the format 111-44-1221.
SELECT email
FROM customers
WHERE email NOT LIKE '%_@__%.__%'
Since spotting these discrepancies in a table of a million records is extremely difficult, here's a more automated approach.
from pyspark.sql import SparkSession
from pyspark.sql.functions import col
spark = SparkSession.builder.appName("SparkSession").getOrCreate()
def test_validity(table_name, column_name, pattern):
df = spark.table(table_name)
invalid_df = df.filter(~col(column_name).rlike(pattern)).select(column_name)
return invalid_df
df = test_validity("customers", "email", r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
display(df)
Below is a table of common regex patterns you can use with this script.
Data Type | Regex Pattern | Example Match |
^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ | ||
Phone Number (US) | ^\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$ | (312) 555-0148 or 312-555-0148 |
Social Security Number (SSN) | ^\d{3}-\d{2}-\d{4}$ | 123-45-6789 |
Credit Card Number | ^\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{1,7}$ | 4111 1111 1111 1111 |
Federal Tax ID / EIN | ^\d{2}-\d{7}$ | 12-3456789 |
ZIP Code (US) | ^\d{5}(-\d{4})?$ | 60601 or 60601-1234 |
IP Address (IPv4) | ^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$ | 192.168.1.1 |
Date (YYYY-MM-DD) | ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$ | 2026-08-12 |
URL | ^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/[\w\-./?%&=]*)?$ |
5. Data Freshness: Ensure Your Data Is Up to Date

This is the easiest but probably most useful check. If your data isn't current, your users will know and escalate quickly. Setting up checks to alert you early is critical. This gives you the opportunity to proactively warn users and stop the flood of angry emails in your inbox.
SELECT MAX(updated_at) AS last_update
FROM orders
Use the Python script below to find the maximum date across every table in a schema or database. Oftentimes, this is an updated_at or created_at column. There is usually a standard naming convention for these columns in every database.
from pyspark.sql import SparkSession
from pyspark.sql.functions import max as spark_max
spark = SparkSession.builder.appName("SparkSession").getOrCreate()
def test_freshness(schema_name, date_column="updated_at"):
tables = spark.catalog.listTables(schema_name)
results = []
for t in tables:
table_name = f"{schema_name}.{t.name}"
df = spark.table(table_name)
if date_column not in df.columns:
results.append((table_name, None))
continue
last_update = df.agg(spark_max(date_column)).collect()[0][0]
results.append((table_name, last_update))
df = spark.createDataFrame(results, ["table", "last_update"])
return df
df = test_freshness('finance', "updated_at")
display(df)
6. Schema Consistency: Ensure Data Matches Your Data Contract
The SQL query below gives you the data type of every column for a particular table. If you have a separate data contract, you can use this query to compare against it and easily locate discrepancies. Oftentimes, tables in the bronze layer ingest all columns as strings (or varchar) to expedite the process, but that approach can leave date fields stored as text instead of being converted to a proper date or integer datatype.
SELECT column_name
, data_type
FROM information_schema.columns
WHERE table_name = 'orders'
To level up your skill set, use the Python script below.
import pandas as pd
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("SparkSession").getOrCreate()
def test_schema_consistency(schema_name, excel_path, sheet_name=0):
# Load expected schema from Excel
expected_df = pd.read_excel(excel_path, sheet_name=sheet_name)
expected_df.columns = [c.strip().lower() for c in expected_df.columns]
expected_lookup = {
(row["table"], row["column"]): str(row["data_type"])
for , row in expecteddf.iterrows()
}
# Pull actual schema for every table in the schema via the Spark catalog
actual_lookup = {}
for t in spark.catalog.listTables(schema_name):
for c in spark.catalog.listColumns(t.name, schema_name):
mismatches = []
# Expected columns that are missing or have a mismatched data type
for (table, column), expected_type in expected_lookup.items():
actual_type = actual_lookup.get((table, column))
if actual_type is None:
mismatches.append((table, column, expected_type, None, "missing_column"))
elif actual_type.lower() != expected_type.lower():
mismatches.append((table, column, expected_type, actual_type, "type_mismatch"))
# Columns that exist in the actual schema but aren't expected
for (table, column), actual_type in actual_lookup.items():
if (table, column) not in expected_lookup:
mismatches.append((table, column, None, actual_type, "unexpected_column"))
df = spark.createDataFrame(
mismatches,
["table", "column", "expected_data_type", "actual_data_type", "issue"],
)
return df
df = test_schema_consistency('finance', 'finance_data_contract.xlsx')
display(df)
7. Anomaly Detection: Try to Find Outliers

This issue is much more challenging to find and more painful to troubleshoot. Sure, you can set constraints like in the SQL example below.
SELECT COUNT(*) AS error_count
FROM orders
WHERE order_date > GETUTCDATE()
OR order_date < '2000-01-01'
However, bad data can still have the correct data type. This is especially true of integer values. A value of 500 might be perfectly acceptable in one column, but that same value in another column could be a huge outlier. Think of this like sports statistics. A typical NFL stadium holds 60,000 to 80,000 people, most teams score around 20 to 30 points per game, and a quarterback may be sacked 1 to 3 times per game. All three of these values have very different expected ranges. A team scoring 14 points in a game is reasonable, but if the quarterback gets sacked 14 times, you may need to replace your offensive line. The script below finds the median of every integer or float column. It then calculates 3 standard deviations by default. Any value outside of 3 standard deviations is considered an outlier. While these outliers may still be accurate values, narrowing the list down helps you find the truly outrageous records. Furthermore, this example only works for a standard bell curve. If your data skews to one side or the other, you will have to modify this script.
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, lit, percentile_approx, stddev
spark = SparkSession.builder.appName("SparkSession").getOrCreate()
NUMERIC_TYPES = ("int", "bigint", "smallint", "tinyint", "float", "double", "decimal")
def test_outliers(table_name, std_dev_allowed=3):
df = spark.table(table_name)
numeric_columns = [
for field in df.schema.fields
if any(field.dataType.simpleString().startswith(t) for t in NUMERIC_TYPES)
]
results = []
for column in numeric_columns:
stats = df.select(
percentile_approx(col(column), 0.5).alias("median"),
stddev(col(column)).alias("std_dev"),
).collect()[0]
median = stats["median"]
std_dev = stats["std_dev"]
# Skip columns that are all null or have zero variance
if median is None or std_dev is None:
continue
three_std = std_dev_allowed * std_dev
lower_bound = median - three_std
upper_bound = median + three_std
outliers = (
df.filter((col(column) < lower_bound) | (col(column) > upper_bound))
.select(col(column).cast("double").alias("value"))
.withColumn("table_name", lit(table_name))
.withColumn("column_name", lit(column))
.withColumn("lower_limit", lit(float(lower_bound)))
.withColumn("upper_limit", lit(float(upper_bound)))
.select("table_name", "column_name", "value", "lower_limit", "upper_limit")
)
results.append(outliers)
if not results:
return spark.createDataFrame(
[],
"table_name string, column_name string, value double, "
"lower_limit double, upper_limit double",
)
final_df = results[0]
for r in results[1:]:
final_df = final_df.unionByName(r)
return final_df
8. Reconciliation: Check That Source and Target Tables Match
It's always wise to check whether your total row counts match between your source table and your target table. The SQL query below returns the row count for both tables.
SELECT
(SELECT COUNT(*) FROM source_db.orders) AS source_count
, (SELECT COUNT(*) FROM warehouse.orders) AS target_count
WHERE source_count <> target_count
The code below is more advanced. It hashes every shared field between both tables and joins on the key. This flags keys missing from either side, plus keys present in both tables with a mismatched value in another column. This row-by-row approach will find the most minor errors. You will often find issues if your data is not fresh. In this case, recent updates have not been applied to the target table, so they will be flagged.
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, sha2, concat_ws, when, lit
spark = SparkSession.builder.appName("SparkSession").getOrCreate()
def test_reconciliation(source_table, target_table, key_column):
source_df = spark.table(source_table)
target_df = spark.table(target_table)
source_count = source_df.count()
target_count = target_df.count()
if source_count != target_count:
print(f"Row count mismatch: {source_table}={source_count}, {target_table}={target_count}")
# Only hash columns that exist on both sides, so a schema difference
# doesn't silently throw off the comparison
common_cols = sorted(set(source_df.columns) & set(target_df.columns))
mismatched_cols = set(source_df.columns) ^ set(target_df.columns)
if mismatched_cols:
print(f"Columns not present on both sides, excluded from hash: {mismatched_cols}")
def row_hash(df):
# hash every shared column into a single value per row
return sha2(concat_ws("|", *[col(c).cast("string") for c in common_cols]), 256)
source_hashed = source_df.select(
col(key_column).alias("key_value"),
row_hash(source_df).alias("source_hash"),
)
target_hashed = target_df.select(
col(key_column).alias("key_value"),
row_hash(target_df).alias("target_hash"),
)
# Full outer join on the key so rows missing on either side show up
# as NULL instead of just disappearing
compared = source_hashed.join(target_hashed, on="key_value", how="full_outer")
df = (
compared.withColumn(
"issue",
when(col("source_hash").isNull(), lit("missing_from_source"))
.when(col("target_hash").isNull(), lit("missing_from_target"))
.when(col("source_hash") != col("target_hash"), lit("value_mismatch"))
.otherwise(lit(None)),
)
.filter(col("issue").isNotNull())
.select("key_value", "issue")
)
return df
df = test_reconciliation(source_table, target_table, key_column)
display(df)
Conclusion
None of these checks require a dedicated data quality platform to get started. Just open a notebook in Fabric or Databricks and copy these reusable functions. In my experience, I create a separate notebook to run at the end of a pipeline. Let the results write out to a log file. Then set up an automated email to the team when these errors occur. I also create separate control tables to dynamically pass parameters into the functions.
If you would rather not build and maintain these checks yourself, there are established frameworks that do a lot of this for you. Great Expectations, DQX from Databricks Labs, DBT tests, Monte Carlo, and Soda Core all have built-in features to help. Depending on your existing tooling, any of these can replace or sit alongside the functions in this article.
These controls may take a little bit of time to set up, but the end gain is huge. You now own the process. No more frustrated users or emotional executives. Let your system work harder as you work smarter.





Comments