Skip to content

Working with Legacy Databases

HyperDjango can integrate with existing PostgreSQL databases. The hyper inspectdb command reverse-engineers your database schema into HyperDjango Model classes, letting you access legacy data through the ORM without manual model writing.

Overview

The workflow for integrating a legacy database:

  1. Point HyperDjango at your existing database (via DATABASE_URL).
  2. Run hyper inspectdb to generate Model classes from the schema.
  3. Review and adjust the generated models.
  4. Use the models through the ORM as normal.

Connecting to an Existing Database

Set DATABASE_URL to your existing PostgreSQL connection string:

export DATABASE_URL="postgres://user:password@host:5432/legacy_db"

Or pass it directly to the command:

hyper inspectdb --database "postgres://user:password@host:5432/legacy_db"

The inspectdb Command

hyper inspectdb connects to the database, reads the schema via PostgreSQL's information_schema and pg_catalog, and generates Python Model classes.

Basic Usage

# Inspect all tables in the public schema
hyper inspectdb

# Save output to a file
hyper inspectdb --output models.py

# Inspect specific tables only
hyper inspectdb --table users orders products

# Inspect a different schema
hyper inspectdb --schema inventory

# Include views and materialized views
hyper inspectdb --include-views

Example Output

For a database with users and orders tables:

"""
# Auto-generated by `hyper inspectdb`
# Schema: public
# Tables: 2
"""

from hyperdjango.models import Model, Field

class User(Model):
    id: int = Field(primary_key=True)
    email: str = Field(unique=True)
    name: str = Field(nullable=True)
    created_at: datetime = Field(nullable=True)

    class Meta:
        table_name = "users"
        managed = False

class Order(Model):
    id: int = Field(primary_key=True)
    user_id: int = Field(foreign_key="User")
    total: Decimal = Field()
    status: str = Field(default="pending")
    created_at: datetime = Field()

    class Meta:
        table_name = "orders"
        managed = False

The managed = False setting tells HyperDjango not to create, modify, or delete the table -- it treats the schema as externally managed.

Type Mapping

inspectdb maps PostgreSQL types to Python types:

PostgreSQL Type Python Type
int2, smallint int
int4, integer, serial int
int8, bigint, bigserial int
float4, real float
float8, double precision float
numeric, decimal, money Decimal
bool, boolean bool
text, varchar, char, bpchar, name str
uuid, json, jsonb, xml, inet, cidr, macaddr str
timestamptz, timestamp datetime
date date
time, timetz time
interval str
bytea bytes

Types not in the map default to str.

Handling Tables Without Primary Keys

PostgreSQL allows tables without explicit primary keys. When inspectdb encounters such a table, it generates a model without a primary_key=True field. You should add one manually:

# Generated (no PK detected)
class LegacyLog(Model):
    message: str = Field()
    created_at: datetime = Field()

    class Meta:
        table_name = "legacy_log"
        managed = False

# Fixed: add a synthetic PK or use an existing unique column
class LegacyLog(Model):
    ctid: str = Field(primary_key=True)  # PostgreSQL physical row ID
    message: str = Field()
    created_at: datetime = Field()

    class Meta:
        table_name = "legacy_log"
        managed = False

Alternatively, if there is a unique column that can serve as a PK, mark it:

class LegacyLog(Model):
    log_id: int = Field(primary_key=True)  # Was UNIQUE, promote to PK
    message: str = Field()
    created_at: datetime = Field()

    class Meta:
        table_name = "legacy_log"
        managed = False

Foreign Keys and Constraints

inspectdb detects foreign key constraints and generates foreign_key references. Tables are topologically sorted so that referenced models appear before referencing models.

# inspectdb detects: orders.user_id -> users.id
class Order(Model):
    id: int = Field(primary_key=True)
    user_id: int = Field(foreign_key="User")
    ...

Circular Foreign Keys

If tables have circular FK dependencies (A references B, B references A), inspectdb falls back to alphabetical ordering. You may need to use string references:

class Department(Model):
    id: int = Field(primary_key=True)
    manager_id: int = Field(foreign_key="Employee", nullable=True)

class Employee(Model):
    id: int = Field(primary_key=True)
    department_id: int = Field(foreign_key="Department")

Unique Constraints and Nullable Columns

inspectdb reads constraint and column metadata:

  • UNIQUE constraints become unique=True on the field.
  • NOT NULL columns have no nullable marker; nullable columns get nullable=True.
  • DEFAULT values are included where detected.

Table and Column Naming

inspectdb converts table names to PascalCase class names and preserves column names as-is. If a column name is a Python reserved word (e.g., class, return, type), it is appended with _field:

# Table: user_sessions, column: class
class UserSession(Model):
    id: int = Field(primary_key=True)
    class_field: str = Field(column_name="class")  # Renamed to avoid keyword conflict

Incremental Migration from Legacy Schema

To gradually take ownership of a legacy schema:

Step 1: Start Unmanaged

Generate models with managed = False. HyperDjango reads from and writes to the existing tables without altering the schema.

class Customer(Model):
    id: int = Field(primary_key=True)
    name: str = Field()

    class Meta:
        table_name = "customers"
        managed = False  # HyperDjango does not touch the schema

Step 2: Add New Columns via Migration

When you need new columns, switch to managed = True and use the migration system:

class Customer(Model):
    id: int = Field(primary_key=True)
    name: str = Field()
    email: str = Field(nullable=True)  # New column

    class Meta:
        table_name = "customers"
        managed = True  # Now HyperDjango manages this table
hyper makemigrations
hyper migrate

Step 3: Coexist with Other Applications

If the legacy database is shared with other applications, keep managed = False and apply schema changes through your existing migration tool (Flyway, Liquibase, raw SQL). HyperDjango models will pick up schema changes on restart.

Full Example: Connecting to an Existing Database

# app.py
from hyperdjango import HyperApp
from hyperdjango.database import Database

app = HyperApp("legacy_app")

@app.on_startup
async def setup():
    db = Database("postgres://user:pass@db-host:5432/legacy_production")
    await db.connect()
    app.db = db

# models.py (generated by: hyper inspectdb --output models.py)
from hyperdjango.models import Model, Field
from datetime import datetime
from decimal import Decimal

class Product(Model):
    id: int = Field(primary_key=True)
    sku: str = Field(unique=True)
    name: str = Field()
    price: Decimal = Field()
    created_at: datetime = Field()

    class Meta:
        table_name = "products"
        managed = False

# routes.py
@app.route("GET", "/products")
async def list_products(request):
    products = await Product.objects.order_by("-created_at").all()
    return Response.json([p.to_dict() for p in products])

@app.route("GET", "/products/{sku}")
async def get_product(request, sku: str):
    product = await Product.objects.get(sku=sku)
    return Response.json(product.to_dict())

Inspecting Views and Materialized Views

Use --include-views to also generate models for database views:

hyper inspectdb --include-views

Views are generated with managed = False (since they cannot be managed through model migrations). Materialized views can be refreshed via raw SQL:

await db.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY product_stats")

See Also

  • Database -- connection pool and query execution
  • Models -- model definition and field types
  • Migrations -- schema migration system
  • CLI -- all hyper commands including inspectdb
  • PostgreSQL Extensions -- ArrayField, full-text search, and other PostgreSQL-specific features