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
dumpdataandloaddatafor 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 field | MySQL | PostgreSQL |
|---|---|---|
| BigAutoField (the id) | bigint AUTO_INCREMENT | bigint GENERATED BY DEFAULT AS IDENTITY |
| BooleanField | bool (stored as tinyint(1)) | boolean |
| DateTimeField | datetime(6), no time zone | timestamp with time zone |
| UUIDField | char(32) | uuid |
| JSONField | json | jsonb |
| DurationField | bigint (microseconds) | interval |
| GenericIPAddressField | char(39) | inet |
| PositiveIntegerField | integer UNSIGNED | integer 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.objectsfound.filter( email= "anna@example.com") Anna@Example.comon 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(logged in the userusername="LOUIE") louieon MySQL and returnedNoneon 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
| Method | Writes paused for | Best for |
|---|---|---|
| dumpdata and loaddata | The whole copy (about 3 minutes for our 520,000 rows) | Small databases, no extra tools |
| pgloader | The whole copy (about 3 seconds for the same rows) | Most sites that can accept a short read-only window |
| CDC replication | Seconds, at cutover | Large 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:
--alluses 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-foreignand the two excludes let PostgreSQL keep the content types and permissions thatmigratecreated and link to them by name. Without them the load failed withduplicate key value violates unique constraint "django_content_type_, as content type IDs on an older database often differ from a fresh one's.app_label_model_ 76bd3d3b_uniq" --format xmlkeeps 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 jsonlis 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, truncateloads into Django's tables, dropping and recreating the foreign keys around the load. Our constraints and indexes matched Django's exactly afterwards. Withdata only, every foreign key was gone afterwards. Addingno foreign keyskept them, but parallel loads then violated them and left three auth tables empty.disable triggersavoids that but needs a superuser, which managed PostgreSQL services don't give you.- The
timezonesetting. 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
CASTrule, one perDurationField. 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 SCHEMAloads intopublicinstead 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
CDCLatencySourceandCDCLatencyTargetmetrics 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.. 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
- Rehearse on a copy of production restored from a backup, and time it (our guide to backing up a website covers testing restores).
- 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 allowSET GLOBAL, use its settings instead (on Amazon RDS,read_onlyin the parameter group). - Finish the copy. Run pgloader or
loaddata, or wait for replication lag to reach zero and stop the task. - Reset the sequences and run
compare_databases. - Switch. Point
DATABASE_URLat 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.