Start With the Data Model, Not the UI

When vibe coding a new app, I always advise starting with your Postgres schema — not a button or a layout. Once your tables exist on Railway, you hand the schema to your AI agent and let it introspect the data model to craft a purpose-built UI/UX that perfectly mirrors your structure.

Below are three production-ready schema boilerplates for specific business verticals, a Railway-compatible Python creation script, and a guide to running safe, non-destructive migrations.

PostgreSQL Railway Python + psycopg2 Vibe Coding

Why Define the Schema First?

The schema is the spec. Everything else — routes, forms, views — flows from it.

Schema as Spec

Your tables define every entity, relationship, and constraint your app will ever handle. It is the single source of truth before a single line of Flask code is written.

AI Introspection

Give your AI agent the schema and it knows exactly what fields to put in forms, what columns to show in tables, and what relationships to navigate — zero guessing.

Zero Throwaway UI

When the UI is derived from the data model, it fits perfectly from day one. No "I need to add a column" mid-build. No data migrations born from UI afterthoughts.

Ready-to-Copy Schema Boilerplates

Three niche use cases. Production-grade schemas. Copy the SQL, run the script below, and start building.

CRM for Dog Walkers

Manage clients, their dogs, scheduled walks, and invoicing. Tracks walker assignment, walk duration and status, emergency contacts per client, vet info per dog, and line-item invoices.

clients dogs walks invoices invoice_items
-- ============================================================
-- Dog Walker CRM — PostgreSQL Schema
-- All tables use IF NOT EXISTS: safe to re-run at any time.
-- ============================================================

CREATE TABLE IF NOT EXISTS clients (
    id                  SERIAL PRIMARY KEY,
    first_name          VARCHAR(100) NOT NULL,
    last_name           VARCHAR(100) NOT NULL,
    email               VARCHAR(255) UNIQUE,
    phone               VARCHAR(20),
    address             TEXT,
    emergency_contact   VARCHAR(255),
    emergency_phone     VARCHAR(20),
    notes               TEXT,
    created_at          TIMESTAMP DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS dogs (
    id                   SERIAL PRIMARY KEY,
    client_id            INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
    name                 VARCHAR(100) NOT NULL,
    breed                VARCHAR(100),
    age_years            INTEGER,
    weight_kg            NUMERIC(5,2),
    vet_name             VARCHAR(255),
    vet_phone            VARCHAR(20),
    medical_notes        TEXT,
    feeding_instructions TEXT,
    created_at           TIMESTAMP DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS walks (
    id               SERIAL PRIMARY KEY,
    dog_id           INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
    walker_name      VARCHAR(100),
    scheduled_at     TIMESTAMP NOT NULL,
    started_at       TIMESTAMP,
    ended_at         TIMESTAMP,
    duration_minutes INTEGER,
    status           VARCHAR(20) DEFAULT 'scheduled'
                     CHECK (status IN ('scheduled','in_progress','completed','cancelled')),
    notes            TEXT,
    created_at       TIMESTAMP DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS invoices (
    id           SERIAL PRIMARY KEY,
    client_id    INTEGER NOT NULL REFERENCES clients(id),
    period_start DATE NOT NULL,
    period_end   DATE NOT NULL,
    total_amount NUMERIC(10,2) NOT NULL,
    status       VARCHAR(20) DEFAULT 'draft'
                 CHECK (status IN ('draft','sent','paid','overdue')),
    issued_at    TIMESTAMP,
    paid_at      TIMESTAMP,
    notes        TEXT,
    created_at   TIMESTAMP DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS invoice_items (
    id          SERIAL PRIMARY KEY,
    invoice_id  INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
    walk_id     INTEGER REFERENCES walks(id),
    description VARCHAR(255) NOT NULL,
    quantity    INTEGER DEFAULT 1,
    unit_price  NUMERIC(10,2) NOT NULL,
    line_total  NUMERIC(10,2) NOT NULL
);

Create Your Tables on Railway

Copy the schema above, drop it into the script below, and run it once. Your tables will be created on your remote Railway Postgres database.

Install dependencies

psycopg2-binary and python-dotenv

Set your .env variables

Copy from your Railway project dashboard

Run the script

python create_tables.py — done

Step 1 — Install dependencies

pip install psycopg2-binary python-dotenv

Step 2 — Set your .env file

Get these values from your Railway project → your Postgres service → Connect tab.

PGDATABASE=railway
PGUSER=postgres
PGPASSWORD=your-railway-password
PGHOST=your-host.railway.app
PGPORT=5432

Step 3 — create_tables.py

Save your chosen schema as schema.sql and place both files in the same folder (or at the root of your project). The script reads the SQL file directly — no pasting, no formatting risk.

import os
import psycopg2
from dotenv import load_dotenv

load_dotenv()


def get_connection():
    """Create a connection to your Railway Postgres database."""
    return psycopg2.connect(
        dbname=os.environ["PGDATABASE"],
        user=os.environ["PGUSER"],
        password=os.environ["PGPASSWORD"],
        host=os.environ["PGHOST"],
        port=os.environ.get("PGPORT", 5432),
        sslmode="require"  # required by Railway
    )


def create_tables():
    """Read schema.sql from the same folder and run it against the database."""
    script_dir = os.path.dirname(os.path.abspath(__file__))
    sql_path   = os.path.join(script_dir, "schema.sql")

    with open(sql_path, "r") as f:
        schema_sql = f.read()

    conn = get_connection()
    cur  = conn.cursor()
    try:
        cur.execute(schema_sql)
        conn.commit()
        print("✓ Tables created successfully.")
    except Exception as e:
        conn.rollback()
        print(f"✗ Error creating tables: {e}")
        raise
    finally:
        cur.close()
        conn.close()


if __name__ == "__main__":
    create_tables()

Expected folder layout:  schema.sql  and  create_tables.py  side by side, plus your  .env  file.

Adding Columns Without Breaking Anything

Your app is live. Users are in the database. You need to add a column. Here is the only safe way to do it.

The Golden Rule: ADD COLUMN IF NOT EXISTS

PostgreSQL's ADD COLUMN IF NOT EXISTS is idempotent — it only adds the column if it doesn't already exist, and does nothing (without error) if it does. This makes your migration scripts safe to run multiple times without side effects.

  • No downtime required for adding a nullable column
  • Existing rows get NULL (or your DEFAULT) for the new column
  • Safe to re-run — running it twice does nothing on the second pass
  • Never use DROP COLUMN on a live database without a backup
  • Never use ALTER COLUMN TYPE without explicit casting — it rewrites the table

migrate.py — run it from your machine, same as the creation script

Same connection setup as create_tables.py. Each migration is a labeled SQL string — append new ones at the bottom as your schema evolves. Safe to re-run at any time: ADD COLUMN IF NOT EXISTS does nothing if the column already exists.

import os
import psycopg2
from dotenv import load_dotenv

load_dotenv()


def get_connection():
    """Create a connection to your Railway Postgres database."""
    return psycopg2.connect(
        dbname=os.environ["PGDATABASE"],
        user=os.environ["PGUSER"],
        password=os.environ["PGPASSWORD"],
        host=os.environ["PGHOST"],
        port=os.environ.get("PGPORT", 5432),
        sslmode="require"
    )


def run_migration(description, sql):
    """
    Execute a single migration statement against the database.
    Each call opens and closes its own connection so a failed
    migration never blocks the ones that follow.
    """
    conn = get_connection()
    cur  = conn.cursor()
    try:
        cur.execute(sql)
        conn.commit()
        print(f"  ✓ {description}")
    except Exception as e:
        conn.rollback()
        print(f"  ✗ {description} — FAILED: {e}")
        raise
    finally:
        cur.close()
        conn.close()


if __name__ == "__main__":
    print("Running migrations...")

    # ── Append new migrations here. Safe to re-run — IF NOT EXISTS is idempotent.

    run_migration(
        "Add tags column to clients",
        "ALTER TABLE clients ADD COLUMN IF NOT EXISTS tags TEXT[];"
    )
    run_migration(
        "Add referred_by column to clients",
        "ALTER TABLE clients ADD COLUMN IF NOT EXISTS referred_by VARCHAR(255);"
    )
    run_migration(
        "Add github_url column to projects",
        "ALTER TABLE projects ADD COLUMN IF NOT EXISTS github_url VARCHAR(255);"
    )
    run_migration(
        "Add is_warranty_job flag to jobs",
        "ALTER TABLE jobs ADD COLUMN IF NOT EXISTS is_warranty_job BOOLEAN DEFAULT FALSE;"
    )

    print("Done.")

Join the Movement

Get tutorials, tools, and insights on building with Flask + PostgreSQL + Vanilla JS.

No spam. Unsubscribe anytime. We respect your inbox.