Quality is not optional. It's our standard. Free QA tools for testers and developers.

How to Generate Test Data for MySQL Without Breaking Constraints

How to Generate Test Data for MySQL Without Breaking Constraints

Generating test data for MySQL sounds straightforward until you hit a foreign key violation, a duplicate key error, or a CHECK constraint that rejects your carefully crafted rows. The problem isn't the data itself—it's the order of operations, the hidden dependencies, and the assumptions your schema makes about the world.

This guide walks through a repeatable process for generating constraint-safe test data in MySQL. It covers schema analysis, generation strategies, validation techniques, and the failure modes that bite teams most often.


The Core Problem: Constraints Are Directed Acyclic Graphs

Every MySQL constraint creates a dependency. A foreign key from orders.customer_id to customers.id means customers must exist before orders. A UNIQUE constraint on users.email means you can't generate two rows with the same email. A CHECK (status IN ('active','pending','closed')) means your random string generator will fail.

The schema defines a directed acyclic graph (DAG) of insertion order. If you ignore the graph, you get errors. If you respect it but generate invalid values, you get errors. If you generate valid values but violate business rules not encoded in the schema, your tests pass but your application breaks.


Prerequisites: What You Need Before Generating a Single Row

1. A Current Schema Dump

mysqldump --no-data --routines --triggers -u root -p your_database > schema.sql

You need the exact DDL: table definitions, indexes, foreign keys, check constraints, triggers, and generated columns. If you're working against a migration system (Flyway, Liquibase, Alembic), point to the migration directory instead.

2. Constraint Inventory

Extract every constraint programmatically. This query builds a dependency map:

SELECT 
    tc.table_schema,
    tc.table_name,
    tc.constraint_name,
    tc.constraint_type,
    kcu.column_name,
    kcu.referenced_table_schema,
    kcu.referenced_table_name,
    kcu.referenced_column_name,
    rc.update_rule,
    rc.delete_rule
FROM information_schema.table_constraints tc
LEFT JOIN information_schema.key_column_usage kcu
    ON tc.constraint_name = kcu.constraint_name
    AND tc.table_schema = kcu.table_schema
LEFT JOIN information_schema.referential_constraints rc
    ON tc.constraint_name = rc.constraint_name
    AND tc.table_schema = rc.constraint_schema
WHERE tc.table_schema = 'your_database'
  AND tc.constraint_type IN ('PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK')
ORDER BY tc.table_name, tc.constraint_type;

Save this output. It's your insertion-order blueprint.

3. Data Dictionary for Business Rules

Constraints don't capture everything. Document:

  • Enum-like columns not enforced by CHECK (e.g., user_role values)
  • Format requirements (phone numbers, SSNs, ISO codes)
  • Cross-column rules (end_date >= start_date)
  • Soft-delete patterns (deleted_at IS NULL for active rows)
  • Partitioning or sharding keys

Decision Criteria: Choose Your Generation Strategy

StrategyBest ForConstraint SafetyMaintenance CostRealism
SQL INSERT scriptsSmall datasets, CI pipelines, reproducibilityHigh (you control order)High (manual updates)Low
Stored proceduresComplex logic, recursive hierarchiesHighMediumMedium
Application-level factories (Factory Bot, factory_boy, etc.)ORM-heavy codebases, integration testsMedium (depends on ORM)LowHigh
Dedicated test data tools (QA3, Synthesized, Tonic, Databunker)Large schemas, PII masking, referential integrityHighLowHigh
Custom scripts (Python/Node/Go)One-off needs, weird legacy schemasWhatever you buildHighWhatever you build

Recommendation: Start with a dedicated tool for schema-aware generation. Fall back to custom scripts only for edge cases the tool can't handle. QA3's free test data generator at /tools/test-data-generator handles MySQL constraint graphs automatically and exports SQL or CSV.


Workflow: From Schema to Valid Data

Step 1: Topological Sort of Tables

Foreign keys define a partial order. Compute a topological sort to get a valid insertion sequence.



# Python example using networkx


import networkx as nx
import mysql.connector


def get_insertion_order(conn, schema):
    cursor = conn.cursor(dictionary=True)
    cursor.execute("""
        SELECT 
            tc.table_name,
            kcu.referenced_table_name
        FROM information_schema.table_constraints tc
        JOIN information_schema.key_column_usage kcu
            ON tc.constraint_name = kcu.constraint_name
        WHERE tc.table_schema = %s
          AND tc.constraint_type = 'FOREIGN KEY'
    """, (schema,))
    
    G = nx.DiGraph()
    # Add all tables as nodes first
    cursor.execute("SHOW TABLES")
    tables = [row[f'Tables_in_{schema}'] for row in cursor.fetchall()]
    G.add_nodes_from(tables)
    
    # Add edges: referenced -> dependent
    for row in cursor.fetchall():
        if row['referenced_table_name'] in tables:
            G.add_edge(row['referenced_table_name'], row['table_name'])
    
    return list(nx.topological_sort(G))

Edge case: Circular foreign keys (e.g., users.manager_id -> users.id). Break cycles by:

  1. Inserting root nodes first (where manager_id IS NULL)
  2. Updating manager_id in a second pass
  3. Or using SET FOREIGN_KEY_CHECKS = 0 temporarily (see Pitfalls)

Step 2: Column-Level Generation Rules

For each column, define a generator that respects its constraints:

Column TypeConstraintsGenerator Approach
AUTO_INCREMENT PKPRIMARY KEY, NOT NULLOmit from INSERT; let MySQL assign
UUID PKPRIMARY KEY, NOT NULLUUID() or application-generated
FK columnFOREIGN KEY, possibly NULLSample from parent table's PK values
UNIQUE columnUNIQUE, NOT NULLSequential (user_1, user_2), UUID, or counter
CHECK columnCHECK (col IN (...))Weighted random from allowed values
ENUMImplicit constraintRandom from enum values
NOT NULL + DEFAULTNOT NULL, DEFAULT 'value'Omit from INSERT; use default
NOT NULL no defaultNOT NULLRequired generator
NULL allowedNo NOT NULLGenerator with NULL probability (e.g., 10%)
Generated columnGENERATED ALWAYS AS (...)Omit entirely—MySQL computes it
TIMESTAMP auto-updateDEFAULT CURRENT_TIMESTAMP ON UPDATEOmit on insert; let MySQL set

Step 3: Handle Referential Integrity in Batches

Don't insert row-by-row. Batch by table, but maintain parent-child order.

-- 1. Parent tables first (no FK dependencies or only self-referencing)
INSERT INTO `customers` (`id`, `email`, `name`, `status`, `created_at`)
VALUES 
    (1, 'alice@example.com', 'Alice', 'active', '2024-01-15 10:00:00'),
    (2, 'bob@example.com', 'Bob', 'pending', '2024-01-16 11:00:00'),
    (3, 'carol@example.com', 'Carol', 'active', '2024-01-17 12:00:00');


-- 2. Child tables reference parent PKs
INSERT INTO `orders` (`id`, `customer_id`, `total`, `status`, `created_at`)
VALUES 
    (1, 1, 99.50, 'completed', '2024-01-15 10:30:00'),
    (2, 1, 149.00, 'pending', '2024-01-16 14:00:00'),
    (3, 2, 75.00, 'cancelled', '2024-01-17 09:00:00');


-- 3. Grandchild tables
INSERT INTO `order_items` (`id`, `order_id`, `product_id`, `quantity`, `unit_price`)
VALUES 
    (1, 1, 101, 2, 49.75),
    (2, 1, 102, 1, 0.00),  -- free add-on
    (3, 2, 103, 3, 49.67);

Step 4: Validate After Generation

Run constraint checks programmatically before handing data to tests:

-- Check FK violations
SELECT 'orders' AS table_name, 'customer_id' AS column_name, COUNT(*) AS violations
FROM `orders` o
LEFT JOIN `customers` c ON o.customer_id = c.id
WHERE c.id IS NULL AND o.customer_id IS NOT NULL


UNION ALL


-- Check UNIQUE violations
SELECT 'customers', 'email', COUNT(*) - COUNT(DISTINCT email)
FROM `customers`


UNION ALL


-- Check CHECK constraints (MySQL 8.0.16+)
SELECT 'orders', 'status', COUNT(*)
FROM `orders`
WHERE status NOT IN ('pending','completed','cancelled','refunded')


UNION ALL


-- Check NOT NULL violations
SELECT 'customers', 'email', COUNT(*)
FROM `customers`
WHERE email IS NULL;

Expected result: zero rows.


Worked Example: E-Commerce Schema

Schema Definition

CREATE DATABASE IF NOT EXISTS `shop_test`;
USE `shop_test`;


CREATE TABLE `users` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `uuid` CHAR(36) NOT NULL DEFAULT (UUID()),
    `email` VARCHAR(255) NOT NULL,
    `password_hash` VARCHAR(255) NOT NULL,
    `role` ENUM('customer','admin','support') NOT NULL DEFAULT 'customer',
    `status` VARCHAR(20) NOT NULL DEFAULT 'active',
    `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `deleted_at` TIMESTAMP NULL DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_users_email` (`email`),
    UNIQUE KEY `uk_users_uuid` (`uuid`),
    CONSTRAINT `chk_users_status` CHECK (`status` IN ('active','inactive','suspended','pending_verification'))
) ENGINE=InnoDB;


CREATE TABLE `addresses` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `user_id` BIGINT UNSIGNED NOT NULL,
    `type` ENUM('billing','shipping') NOT NULL,
    `line1` VARCHAR(255) NOT NULL,
    `line2` VARCHAR(255) DEFAULT NULL,
    `city` VARCHAR(100) NOT NULL,
    `state` VARCHAR(100) NOT NULL,
    `postal_code` VARCHAR(20) NOT NULL,
    `country_code` CHAR(2) NOT NULL DEFAULT 'US',
    `is_default` BOOLEAN NOT NULL DEFAULT FALSE,
    `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    CONSTRAINT `fk_addresses_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
    CONSTRAINT `chk_addresses_country` CHECK (`country_code` REGEXP '^[A-Z]{2}$')
) ENGINE=InnoDB;


CREATE TABLE `products` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `sku` VARCHAR(50) NOT NULL,
    `name` VARCHAR(255) NOT NULL,
    `description` TEXT,
    `price_cents` INT UNSIGNED NOT NULL,
    `currency` CHAR(3) NOT NULL DEFAULT 'USD',
    `stock_quantity` INT UNSIGNED NOT NULL DEFAULT 0,
    `status` ENUM('active','discontinued','draft') NOT NULL DEFAULT 'draft',
    `weight_grams` INT UNSIGNED DEFAULT NULL,
    `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_products_sku` (`sku`),
    CONSTRAINT `chk_products_price` CHECK (`price_cents` >= 0),
    CONSTRAINT `chk_products_currency` CHECK (`currency` IN ('USD','EUR','GBP','CAD'))
) ENGINE=InnoDB;


CREATE TABLE `orders` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `order_number` VARCHAR(20) NOT NULL,
    `user_id` BIGINT UNSIGNED NOT NULL,
    `billing_address_id` BIGINT UNSIGNED NOT NULL,
    `shipping_address_id` BIGINT UNSIGNED NOT NULL,
    `subtotal_cents` INT UNSIGNED NOT NULL,
    `tax_cents` INT UNSIGNED NOT NULL DEFAULT 0,
    `shipping_cents` INT UNSIGNED NOT NULL DEFAULT 0,
    `total_cents` INT UNSIGNED NOT NULL,
    `status` ENUM('pending','confirmed','processing','shipped','delivered','cancelled','refunded') NOT NULL DEFAULT 'pending',
    `placed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `shipped_at` TIMESTAMP NULL DEFAULT NULL,
    `delivered_at` TIMESTAMP NULL DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_orders_number` (`order_number`),
    CONSTRAINT `fk_orders_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT,
    CONSTRAINT `fk_orders_billing_addr` FOREIGN KEY (`billing_address_id`) REFERENCES `addresses` (`id`) ON DELETE RESTRICT,
    CONSTRAINT `fk_orders_shipping_addr` FOREIGN KEY (`shipping_address_id`) REFERENCES `addresses` (`id`) ON DELETE RESTRICT,
    CONSTRAINT `chk_orders_totals` CHECK (`total_cents` = `subtotal_cents` + `tax_cents` + `shipping_cents`),
    CONSTRAINT `chk_orders_dates` CHECK (
        (`shipped_at` IS NULL OR `shipped_at` >= `placed_at`) AND
        (`delivered_at` IS NULL OR `delivered_at` >= `shipped_at`)
    )
) ENGINE=InnoDB;


CREATE TABLE `order_items` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `order_id` BIGINT UNSIGNED NOT NULL,
    `product_id` BIGINT UNSIGNED NOT NULL,
    `quantity` INT UNSIGNED NOT NULL,
    `unit_price_cents` INT UNSIGNED NOT NULL,
    `line_total_cents` INT UNSIGNED NOT NULL,
    PRIMARY KEY (`id`),
    CONSTRAINT `fk_order_items_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE,
    CONSTRAINT `fk_order_items_product` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE RESTRICT,
    CONSTRAINT `chk_order_items_line_total` CHECK (`line_total_cents` = `quantity` * `unit_price_cents`),
    CONSTRAINT `chk_order_items_quantity` CHECK (`quantity` > 0)
) ENGINE=InnoDB;


CREATE TABLE `payments` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `order_id` BIGINT UNSIGNED NOT NULL,
    `payment_method` ENUM('card','bank_transfer','paypal','apple_pay','google_pay') NOT NULL,
    `amount_cents` INT UNSIGNED NOT NULL,
    `currency` CHAR(3) NOT NULL DEFAULT 'USD',
    `status` ENUM('pending','authorized','captured','failed','refund

Read more

How to Generate Test Data for PostgreSQL Databases

A step-by-step guide for “How to Generate Test Data for PostgreSQL Databases,” covering prerequisites, implementation choices, validation, and common failure modes.

When Not to Use AI for Test Data Generation

A practical risk review of “When Not to Use AI for Test Data Generation,” with warning signs, safeguards, and fixes for real QA workflows.

AI Test Data Generation Metrics: Validity, Diversity, and Coverage

A practical guide to “AI Test Data Generation Metrics: Validity, Diversity, and Coverage,” with worked scenarios, tool considerations, validation checks, and actionable advice for QA teams.