DEV Community

Jordan Huang
Jordan Huang

Posted on

I Stopped Hand-Writing Seed Data. Now a Free Model Builds It From My Migration.

My migration test passed again. Twenty minutes later, production failed on a foreign key constraint.

Last week I pushed a small schema change. The test suite said everything was fine. The deployment said otherwise.

Why did my tests lie to me? Because the seed rows I wrote by hand were exactly the rows my migration expected to see.

The Seed-Data Blind Spot

It's not that the migration code was wrong. It's that my test fixture only covered the happy path.

I wrote five rows with valid foreign keys, non-null timestamps, and sane email strings. Nothing challenged the new constraint.

Do you remember every column when you write fixtures? Neither do I.

What I Want Instead

A good fixture set should include:

  • At least one row with a null in each nullable column.
  • At least one row with an empty string where the app expects text.
  • Foreign keys that point to rows that may not exist.
  • Values at the edge of the column type, not just the middle.

I can get that from a free model. I read the migration SQL, send it to a free endpoint, ask for a JSON array of rows, and insert them into a temporary SQLite database.

The Local Generator

Here is the script I use. It reads a schema.sql file, calls a free model endpoint, and returns rows I can insert into SQLite.

import argparse, json, os, sqlite3, urllib.request

def read_schema(path):
    return open(path, encoding='utf-8').read()

def build_prompt(schema):
    return f'''You are a test-data generator.
Given this SQL schema:
{schema}
Return only a JSON array of row objects.
Each object must use column names as keys and valid SQL values.
Generate 6 rows that include nulls, empty strings, and boundary values where sensible.
Do not wrap the JSON in markdown.'''

def call_model(endpoint, key, model, prompt):
    payload = {
        'model': model,
        'messages': [
            {'role': 'system', 'content': 'You return only valid JSON.'},
            {'role': 'user', 'content': prompt},
        ],
        'temperature': 0.4,
    }
    req = urllib.request.Request(
        endpoint,
        data=json.dumps(payload).encode('utf-8'),
        headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {key}'},
    )
    with urllib.request.urlopen(req, timeout=90) as r:
        return json.load(r)

def insert_rows(db_path, table_name, rows):
    con = sqlite3.connect(db_path)
    cur = con.cursor()
    for row in rows:
        columns = ', '.join(row.keys())
        placeholders = ', '.join('?' for _ in row)
        cur.execute(
            f'INSERT INTO {table_name} ({columns}) VALUES ({placeholders})',
            list(row.values()),
        )
    con.commit()
    con.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--schema', default='schema.sql')
    parser.add_argument('--table', required=True)
    parser.add_argument('--db', default='test.db')
    args = parser.parse_args()

    schema = read_schema(args.schema)
    prompt = build_prompt(schema)
    response = call_model(
        os.environ['MONKEYCODE_ENDPOINT'],
        os.environ.get('MONKEYCODE_API_KEY', ''),
        os.environ.get('MODEL_NAME', 'free-current'),
        prompt,
    )
    rows = json.loads(response['choices'][0]['message']['content'])
    insert_rows(args.db, args.table, rows)
    print(f'inserted {len(rows)} rows')
Enter fullscreen mode Exit fullscreen mode

I don't pin a model name in code. Free endpoints rotate, and I don't want my local tool to break when the catalog changes.

The Prompt That Matters

One hard rule: ask for JSON only. The model loves to add an explanation above or below the array. That breaks the parser.

I also set temperature to 0.4. Higher values produce more variety, but they also produce more made-up column names. Lower values get boring and miss edge cases.

Running It Against a Free Endpoint

I use MonkeyCode's free model access and free server option to run this from a small script without keeping a paid service open.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Because the endpoint is free, I don't have to provision a GPU or manage an API tier for this one-off job. I also don't need the result to be instant; even a few seconds per call is fine while I'm waiting for my coffee.

What It Caught

Last week I added a CHECK (age >= 0) constraint to a users table. My hand-written rows were all positive integers, so the check passed.

The model generated a row with age = -1. The insert failed locally. That told me two things: the constraint exists, and my app code had no error path for negative ages.

I fixed the app handling before I committed. The failure stayed on my laptop.

Limits I Hit

The model generated a datetime as a string for a column typed as TEXT. My schema checker caught it before insertion because I whitelist column types.

Free-tier rate limits meant I could generate about 30 rows in one go before the model started repeating itself. I batch requests for larger tables.

The model cannot see foreign key relationships across multiple tables unless I include all relevant DDL. I limit the generator to one table at a time and manually fix cross-table rows.

Who Should Skip This

You need realistic personal data for a demo. A model inventing names and emails is not a privacy boundary.

You need 100,000 rows to soak-test a query plan. A free model will not produce that volume.

You have triggers, checks, or generated columns with semantics the model will never infer from DDL alone.

Bottom Line

Don't write test rows from memory. Let a model read the DDL and propose rows you would never think to include.

The failures stay on your laptop, not in production.

If you already have a free endpoint, try it on your next migration before you write another row.

Top comments (0)