Skip to main content

How to Migrate a Django App from MySQL to PostgreSQL Without Downtime

Updated September 2026. To move a Django project from MySQL to PostgreSQL without taking the site offline, let Django build the new schema, copy the data with pgloader, dumpdata or a replication tool, reset the sequences, prove both databases match, then switch DATABASE_URL during a short read-only window. We ran the commands in this guide against MySQL 8.4 and PostgreSQL 17 with Django 5.2, apart from the AWS-specific ones and the replication tools, and several popular shortcuts silently lost or changed data, so the checks matter as much as the copy.

The short answer

  • Let Django create the PostgreSQL schema with migrate, then copy only the data. Schema-converting tools guess column types, and pgloader chose differently from Django for five of our fields.
  • Choose the copy method by how long you can pause writes: about 3 seconds with pgloader or 3 minutes with dumpdata and loaddata for our 520,000 rows, or just seconds at cutover with a change data capture (CDC) tool such as AWS DMS or Debezium.
  • Reset the sequences after any copy except loaddata, or the first new record fails with a duplicate key error.
  • Compare every table before you switch. pgloader exited with status 0 on runs where it had shifted timestamps by an hour, turned 2 hours into 2,000,000 hours, or loaded none of a table.

How we tested

We built a Django project whose models cover the awkward field types (booleans, UUIDs, JSON, durations, IP addresses, decimals and time zone aware datetimes), plus Django's auth, admin and content type tables. We seeded MySQL with 20,000 orders for the correctness tests and 500,000 for the timings, and compared every row after each method.

Test set-up: Apple M5 Mac, MySQL 8.4.2 and PostgreSQL 17.11 on the same machine, Python 3.12, Django 5.2.17, mysqlclient 2.3.0, psycopg 3.3.6 and pgloader 3.6.10 (the version Homebrew ships). Real migrations, over a network, will be slower.

Step 1: find what PostgreSQL will treat differently

Column types

This is what sqlmigrate generated for our models on each database:

Django fieldMySQLPostgreSQL
BigAutoField (the id)bigint AUTO_INCREMENTbigint GENERATED BY DEFAULT AS IDENTITY
BooleanFieldbool (stored as tinyint(1))boolean
DateTimeFielddatetime(6), no time zonetimestamp with time zone
UUIDFieldchar(32)uuid
JSONFieldjsonjsonb
DurationFieldbigint (microseconds)interval
GenericIPAddressFieldchar(39)inet
PositiveIntegerFieldinteger UNSIGNEDinteger with a CHECK constraint

Left to create the schema itself, pgloader made char columns for UUIDs and IP addresses, bigint for durations and the positive integer, and json instead of jsonb, all in a schema named after the MySQL database. Django expects the right-hand column, so let Django build the schema.

Case- and accent-insensitive matching

MySQL 8's default collation, utf8mb4_0900_ai_ci, ignores case and accents. PostgreSQL compares exactly. On identical data:

  • Customer.objects.filter(email="anna@example.com") found Anna@Example.com on MySQL and nothing on PostgreSQL.
  • filter(name="Jose Garcia") found "José García" on MySQL only.
  • MySQL's unique index rejected a second customer with anna@example.com. PostgreSQL accepted it.
  • Logins became case-sensitive: authenticate(username="LOUIE") logged in the user louie on MySQL and returned None on PostgreSQL.

Switch exact lookups on emails, usernames and slugs to __iexact where that's what you meant, and on your own models add a functional unique constraint (we confirmed it rejects the duplicate on PostgreSQL):

from django.db.models.functions import Lower

class Meta:
    constraints = [
        models.UniqueConstraint(Lower("email"), name="customer_email_ci_unique"),
    ]

Data PostgreSQL refuses

Older MySQL data can contain "zero" dates such as 0000-00-00 and NUL bytes in text, and PostgreSQL accepts neither. We planted both. Django reads a zero date as None, so loaddata failed on a NOT NULL column. One zero date made pgloader load none of our 20,000 orders, and it quietly deleted the NUL byte, which loaddata refused. Fix both on MySQL first:

-- Zero dates. "= '0000-00-00'" errors in strict mode; this works.
SELECT id FROM shop_order WHERE shipped_on < '1000-01-01';
UPDATE shop_order SET shipped_on = NULL WHERE shipped_on < '1000-01-01';

-- NUL bytes, compared as binary so the collation can't give false matches
SELECT id FROM shop_customer WHERE LOCATE(0x00, CAST(notes AS BINARY)) > 0;
UPDATE shop_customer SET notes = REPLACE(notes, CHAR(0), '')
 WHERE LOCATE(0x00, CAST(notes AS BINARY)) > 0;

For a NOT NULL date, pick a real value instead. Also list text columns that aren't utf8mb4: utf8mb3 can't hold emoji, and old latin1 columns sometimes hold wrongly encoded text.

SELECT table_name, column_name, character_set_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
  AND character_set_name NOT IN ('utf8mb4', 'binary');

Step 2: build the PostgreSQL schema with Django

Add a second database alias for the new server and create the tables there:

import os
import dj_database_url

DATABASES = {
    "default": dj_database_url.config(conn_max_age=600),  # MySQL until cutover
    "postgres": dj_database_url.parse(os.environ["POSTGRES_URL"]),
}
python manage.py migrate --database=postgres

Then freeze schema changes until cutover, so both databases keep identical tables.

Step 3: copy the data

MethodWrites paused forBest for
dumpdata and loaddataThe whole copy (about 3 minutes for our 520,000 rows)Small databases, no extra tools
pgloaderThe whole copy (about 3 seconds for the same rows)Most sites that can accept a short read-only window
CDC replicationSeconds, at cutoverLarge databases, or sites that can't pause

Option A: dumpdata and loaddata

python manage.py dumpdata --all --natural-foreign \
    --exclude contenttypes --exclude auth.permission \
    --format xml -o data.xml.gz
python manage.py loaddata --database=postgres data.xml.gz

Each flag fixed a problem we hit:

  • --all uses each model's base manager. Our default manager hid archived orders, so without it 2,042 of 20,000 orders were silently left behind.
  • --natural-foreign and the two excludes let PostgreSQL keep the content types and permissions that migrate created and link to them by name. Without them the load failed with duplicate key value violates unique constraint "django_content_type_app_label_model_76bd3d3b_uniq", as content type IDs on an older database often differ from a fresh one's.
  • --format xml keeps full precision. Django's JSON and JSONL serialisers cut datetimes to milliseconds, which changed every finer timestamp in our JSONL copy. If milliseconds are enough, --format jsonl is fine.

loaddata runs in a single transaction, so a failure leaves nothing half-loaded, and it resets the sequences of the models it loads. It's slow, though: 18 seconds to dump our 520,000 rows and 160 seconds to load them.

Option B: pgloader

pgloader streams rows into PostgreSQL with COPY and is fast, but its defaults assume it owns the schema. This load file gave us an exact copy into Django's tables, connecting as an ordinary database owner rather than a superuser:

LOAD DATABASE
     FROM mysql://migrator:password@mysql-host/appdb
     INTO postgresql://app:password@pg-host/appdb

WITH create no tables, include no drop, truncate

SET PostgreSQL PARAMETERS timezone TO 'UTC'

CAST column shop_order.delivery_window to interval
     using (lambda (us) (when us (format nil "~a microseconds" us)))

ALTER SCHEMA 'appdb' RENAME TO 'public';

Run it with pgloader django.load. What each part fixed:

  • create no tables, include no drop, truncate loads into Django's tables, dropping and recreating the foreign keys around the load. Our constraints and indexes matched Django's exactly afterwards. With data only, every foreign key was gone afterwards. Adding no foreign keys kept them, but parallel loads then violated them and left three auth tables empty. disable triggers avoids that but needs a superuser, which managed PostgreSQL services don't give you.
  • The timezone setting. Django stores UTC in MySQL columns that carry no time zone, and pgloader's session read them in the server's zone, Europe/London in our case. 11,748 orders placed during British Summer Time moved an hour.
  • The CAST rule, one per DurationField. MySQL stores durations as microseconds and PostgreSQL reads a bare number as seconds, so a 2-hour delivery window became 2,000,000 hours.
  • ALTER SCHEMA loads into public instead of a schema named after the MySQL database.

Two warnings. pgloader didn't reset Django's identity sequences, so do Step 4. And it exited with status 0 on every run that had errors, including the one that loaded none of the orders and one with the on error stop option set. Read the errors column in its summary and run Step 5 regardless.

Option C: replication, for near-zero downtime

If you can't pause writes for a whole copy, keep the site on MySQL while a CDC tool copies the existing rows, then streams every later change from MySQL's binary log. Once PostgreSQL catches up, writes pause for seconds while you switch. The main options:

  • AWS DMS, with a task that migrates existing data and replicates ongoing changes: the usual choice when either database is on AWS. Set the target table preparation mode to "Do nothing" so it loads into Django's tables, and watch the CDCLatencySource and CDCLatencyTarget metrics to see when it has caught up.
  • Debezium: open-source MySQL and MariaDB connectors that publish changes to Kafka, with a JDBC sink connector writing them into PostgreSQL. More moving parts, no cloud lock-in.
  • pg_chameleon: an open-source Python tool built specifically to replicate MySQL into PostgreSQL.

All of them read the binlog (MariaDB works the same way), and MySQL 8.4's defaults already suit them: ROW format, FULL row images, 30 days' retention. On Amazon RDS, raise retention to outlast the full load, for example CALL mysql.rds_set_configuration('binlog retention hours', 24);. We checked these settings and this user, with the privileges Debezium's documentation lists, on MySQL 8.4:

SELECT @@log_bin, @@binlog_format, @@binlog_row_image, @@binlog_expire_logs_seconds;

CREATE USER 'cdc'@'%' IDENTIFIED BY 'use-a-long-random-password';
GRANT SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, REPLICATION CLIENT
    ON *.* TO 'cdc'@'%';

Older guides use SHOW MASTER STATUS, which MySQL 8.4 rejects; use SHOW BINARY LOG STATUS. We didn't run DMS or Debezium for this article, so follow their documentation, check how your tool converts durations, time zones and zero dates, and reset sequences yourself.

We'd avoid the alternative of writing to both databases from Django. No transaction spans the two, so a failure between writes leaves them different, and QuerySet.update(), bulk_create() and raw SQL skip the signals most dual-write code relies on.

Step 4: reset the sequences

Django's primary keys on PostgreSQL are identity columns backed by a sequence. Copying rows with their existing IDs doesn't move the sequence on, so after our pgloader load the next insert failed:

IntegrityError: duplicate key value violates unique constraint "shop_customer_pkey"
DETAIL:  Key (id)=(1) already exists.

sqlsequencereset prints a setval() statement per table, and dbshell (which needs psql installed) runs them. This covers every installed app, and we tested it in bash and zsh. Keep --database=postgres: against MySQL it just prints "No sequences found."

python manage.py sqlsequencereset $(python manage.py shell -v 0 -c "from django.apps import apps; print(*{m._meta.app_label for m in apps.get_models()})") --database=postgres | python manage.py dbshell --database=postgres

With replication, run it during cutover, after the last change has arrived.

Step 5: prove the data matches

Row counts alone would have missed most of our problems. This management command counts and checksums every row in both databases through Django, so values are normalised the same way. JSON keys are sorted because jsonb reorders them, and row order doesn't matter:

# yourapp/management/commands/compare_databases.py
import hashlib
import json

from django.apps import apps
from django.core.management.base import BaseCommand, CommandError


class Command(BaseCommand):
    def handle(self, *args, **options):
        differences = 0
        for model in apps.get_models(include_auto_created=True):
            if model._meta.proxy or not model._meta.managed:
                continue
            results = []
            for alias in ("default", "postgres"):
                rows = model._base_manager.using(alias).values_list()
                count, checksum = 0, 0
                for row in rows.iterator(chunk_size=5000):
                    line = json.dumps(row, default=str, sort_keys=True).encode()
                    checksum += int.from_bytes(hashlib.sha256(line).digest()[:8], "big")
                    count += 1
                results.append((count, checksum % 2**64))
            status = "OK  " if results[0] == results[1] else "DIFF"
            differences += status == "DIFF"
            self.stdout.write(f"{status} {model._meta.db_table}: {results[0][0]} rows vs {results[1][0]} rows")
        if differences:
            raise CommandError(f"{differences} table(s) differ")

python manage.py compare_databases took under 7 seconds for our 520,000 rows and caught a 1p change to a single order. If you used Option A's excludes, django_content_type, auth_permission and auth_group_permissions can differ, because PostgreSQL numbered them itself; everything else should say OK. Then run your test suite against PostgreSQL and watch for code that relied on MySQL returning rows in primary key order without order_by().

Step 6: cut over, and keep a way back

  1. Rehearse on a copy of production restored from a backup, and time it (our guide to backing up a website covers testing restores).
  2. Pause writes. Show a maintenance or read-only message, stop background workers and scheduled jobs, then run SET GLOBAL super_read_only = ON; on MySQL. In our test Django could still read, and any write failed with error 1290. Where the host doesn't allow SET GLOBAL, use its settings instead (on Amazon RDS, read_only in the parameter group).
  3. Finish the copy. Run pgloader or loaddata, or wait for replication lag to reach zero and stop the task.
  4. Reset the sequences and run compare_databases.
  5. Switch. Point DATABASE_URL at PostgreSQL, restart every process (web, workers, scheduled jobs), then log in, place a test order and check the admin.

Leave MySQL untouched and read-only. Until the first write lands in PostgreSQL, rolling back means pointing DATABASE_URL back and running SET GLOBAL super_read_only = OFF; SET GLOBAL read_only = OFF; (you need both: switching off the first leaves the second on). After that, going back means copying new PostgreSQL data into MySQL, so decide in advance how long you'll keep that option and write it into your disaster recovery plan.

We build our Django applications on PostgreSQL. If you're planning a move and would like a second pair of eyes on the plan, book a free call.

Share this post: