Configure Django with MySQL in Production

Run Django on MySQL or MariaDB in production: mysqlclient, utf8mb4, strict SQL mode, time zone tables, connection timeouts, TLS, and backups.

Problem statement

PostgreSQL is the most common database for Django, but many teams run MySQL or MariaDB because of existing infrastructure, hosting offers, or shared databases with other applications. Django supports both well, provided the server and connection are configured the way Django expects. Defaults that are merely surprising in development become data bugs in production:

  • a utf8 (3-byte) character set that cannot store emoji and some CJK characters
  • non-strict SQL mode silently truncating values that are too long
  • MySQL server has gone away errors after idle periods
  • date lookups returning wrong results with USE_TZ = True because time zone tables are empty
  • connections sent over the network without TLS to a managed database

This guide configures a MySQL 8 or MariaDB database and Django's connection settings for production, and covers verification and backups.

Django on MySQL in productionDjango ORMmysqlclient driverConnectionutf8mb4, strict modeMySQL serverInnoDB, TLSBackupsmysqldump, snapshotsConnection options enforce correct behaviour regardless of server defaults.

Quick answer

  1. Use a MySQL or MariaDB version supported by your Django release.
  2. Install mysqlclient (the driver Django recommends), with build dependencies if no wheel is available.
  3. Create the database with CHARACTER SET utf8mb4 and a utf8mb4 collation, and a dedicated user limited to that database.
  4. Set OPTIONS with charset: utf8mb4 and init_command: SET sql_mode='STRICT_TRANS_TABLES'.
  5. Keep CONN_MAX_AGE below the server's wait_timeout and enable CONN_HEALTH_CHECKS.
  6. Load time zone tables if you use USE_TZ = True with date lookups in the database.
  7. Require TLS to remote databases and schedule consistent backups.

Step-by-step solution

1. Install the driver

mysqlclient is a C extension. Wheels exist for some platforms; otherwise it compiles and needs headers:

sudo apt install python3-dev default-libmysqlclient-dev build-essential pkg-config
pip install mysqlclient
python -c "import MySQLdb; print(MySQLdb.version_info)"

On Docker images based on python:*-slim, install the same packages in the build stage and only the runtime client library in the final image.

2. Create the database and user

CREATE DATABASE myapp
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

CREATE USER 'myapp'@'10.0.1.%' IDENTIFIED BY 'generated-password';
GRANT ALL PRIVILEGES ON myapp.* TO 'myapp'@'10.0.1.%';
  • utf8mb4 stores the full Unicode range. MySQL's older utf8 alias is a 3-byte encoding that rejects characters such as emoji.
  • MySQL 8 defaults to utf8mb4_0900_ai_ci, which is also a good choice; pick one collation and use it consistently, because mixed collations cause errors when comparing columns across tables.
  • Restrict the host part of the user to your application network instead of %.
  • Grant privileges on the application database only. ALL on that database is needed for migrations; if you separate migration and runtime users, the runtime user needs SELECT, INSERT, UPDATE, DELETE.
"utf8" in MySQL is not full UTF-8utf8 / utf8mb3✕at most 3 bytes per character✕rejects emoji and some CJK characters✕deprecated alias in MySQL 8utf8mb4✓the full Unicode range, 4 bytes✓set on database, tables, and connectionone collation used consistentlyCreate the database as utf8mb4 from the start; converting later rewrites tablesdjango-deployment.com
The connection charset matters as much as the table charset, which the next step sets.

3. Configure Django

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
        "HOST": os.environ["DB_HOST"],
        "PORT": os.environ.get("DB_PORT", "3306"),
        "CONN_MAX_AGE": 60,
        "CONN_HEALTH_CHECKS": True,
        "OPTIONS": {
            "charset": "utf8mb4",
            "init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
            "isolation_level": "read committed",
        },
    }
}
the OPTIONS that prevent silent data problems"OPTIONS": {"charset": "utf8mb4","init_command": "SET sql_mode='STRICT_TRANS_TABLES'","isolation_level": "read committed",},"CONN_MAX_AGE":60,1231The connection itself must speak utf8mb4, or 4-byte characters fail even inutf8mb4 tables.2Reject too-long or invalid values instead of silently truncating them.3Keep well below the server wait_timeout, and pair with CONN_HEALTH_CHECKS =True.Each option replaces a permissive MySQL default with an explicit onedjango-deployment.com
Stating isolation_level explicitly documents a default that differs from MySQL's own.

What these options do:

  • charset makes the connection itself use utf8mb4. A utf8mb4 table is not enough if the connection encoding is narrower.
  • sql_mode with STRICT_TRANS_TABLES makes MySQL reject invalid or too-long values instead of truncating them with a warning. Django's check framework warns (mysql.W002) when strict mode is not enabled.
  • isolation_level is read committed by default in Django. It is stated here so it is visible; MySQL's server default of repeatable read can cause surprising behavior with get_or_create() under concurrency.

If your server sets strict mode globally, you can omit init_command; setting it per connection guarantees the mode regardless of server configuration.

4. Connection lifetime and wait_timeout

MySQL closes connections that stay idle longer than wait_timeout (28800 seconds by default, often lowered on managed services). A persistent Django connection that was closed by the server fails on its next query with MySQL server has gone away (error 2006) unless Django notices first.

SHOW VARIABLES LIKE 'wait_timeout';
reuse connections for less time than the server keeps themMySQL default wait_timeout28,800 s (off scale)Managed service, lowered600 s (example)Django CONN_MAX_AGE60 s0 s300 s600 s900 sA connection closed by the server first surfaces as "MySQL server has gone away"django-deployment.com
Check the actual wait_timeout on your server; managed services often set it far lower than the default.

Keep CONN_MAX_AGE well below that value and set CONN_HEALTH_CHECKS = True, which makes Django check a reused connection at the start of each request and reconnect if it is unusable.

5. Time zone support

With USE_TZ = True, Django stores datetimes in UTC. Queries that extract parts of a date in the database (__date, __year, TruncDay with a time zone) use MySQL's CONVERT_TZ(), which returns NULL when the time zone tables are empty. Load them on self-managed MySQL:

mysql_tzinfo_to_sql /usr/share/zoneinfo | sudo mysql -u root mysql

Verify:

SELECT CONVERT_TZ('2026-01-01 12:00:00', 'UTC', 'Europe/Berlin');

A non-NULL result means the tables are loaded. Managed services usually have them populated already; run the same check.

6. TLS to a remote database

For a database on another host or a managed service, require encrypted connections:

"OPTIONS": {
    "charset": "utf8mb4",
    "init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
    "ssl": {"ca": "/etc/ssl/certs/mysql-ca.pem"},
},

Download the provider's CA bundle and keep it on the server. Enforce TLS on the server side too, for example with REQUIRE SSL on the user or require_secure_transport=ON.

7. Server settings worth checking

SHOW VARIABLES WHERE Variable_name IN
  ('max_connections', 'innodb_buffer_pool_size', 'max_allowed_packet',
   'character_set_server', 'collation_server', 'default_storage_engine');
  • default_storage_engine should be InnoDB, which supports transactions and foreign keys.
  • innodb_buffer_pool_size is the main memory setting; on a dedicated database server it is commonly a large share of RAM.
  • max_allowed_packet limits the size of a single query or row; raise it if you store large text or binary values.
  • max_connections must exceed servers × workers × threads plus Celery and admin connections.

8. Run migrations and checks

python manage.py check --database default
python manage.py migrate --noinput
python manage.py check --deploy

check --database default runs database-specific checks, including the strict mode warning.

MySQL does not support transactional DDL: if a migration fails halfway, schema changes already executed are not rolled back. Keep migrations small, and take a backup before large schema changes.

9. Backups

For InnoDB tables, mysqldump with --single-transaction produces a consistent dump without locking tables for the duration:

mysqldump --single-transaction --routines --triggers \
  --default-character-set=utf8mb4 \
  -h db.internal -u backup -p myapp | gzip > /var/backups/myapp-$(date +%F).sql.gz

For large databases, consider physical backup tools or the managed service's snapshots with point-in-time recovery. Test restores into a scratch database regularly.

10. Verify

python manage.py shell -c "
from django.db import connection
with connection.cursor() as c:
    c.execute('SELECT @@sql_mode, @@character_set_connection, @@transaction_isolation')
    print(c.fetchone())
"

Also test real data:

python manage.py shell -c "
from django.contrib.auth import get_user_model
u = get_user_model().objects.create(username='emoji-test-🚀')
print(get_user_model().objects.get(pk=u.pk).username)
u.delete()
"

Verification check:

  • sql_mode contains STRICT_TRANS_TABLES
  • connection character set is utf8mb4
  • transaction isolation is READ-COMMITTED
  • the emoji username round-trips unchanged
  • CONVERT_TZ returns a value, not NULL

Explanation

Most MySQL problems with Django come from permissive legacy defaults: narrow character sets, lenient SQL modes, and connections that time out silently. Django's OPTIONS let you enforce the right behavior per connection, independent of how a shared or managed server is configured, while database-side settings (collation, time zone tables, TLS requirements) make sure the server agrees.

Edge cases / notes

Converting an existing utf8 database

Converting tables to utf8mb4 rewrites them and can lock large tables. Plan it as a maintenance operation, test on a copy, and check index key length limits on older servers.

Case sensitivity

With _ci collations, comparisons are case-insensitive, so filter(username="Alice") also matches alice. Unique constraints are case-insensitive too. Account for this when migrating from PostgreSQL.

MariaDB differences

MariaDB is supported by Django as a separate backend family with its own version requirements. Test on the exact server you deploy to rather than assuming MySQL and MariaDB behave identically.

Troubleshooting MySQL errors in Django

ErrorCauseFix
(2002) Can't connect to local MySQL server through socketHOST empty or localhost, client tries a Unix socketset HOST to 127.0.0.1 for TCP, or the correct socket path
(1045) Access denied for userwrong password or host part of the user does not matchcheck SELECT user, host FROM mysql.user;
(2006) MySQL server has gone awayidle connection closed, or packet too largelower CONN_MAX_AGE, enable health checks, raise max_allowed_packet
(1366) Incorrect string value: '\xF0\x9F...'4-byte character into a utf8 columnconvert the table and connection to utf8mb4
(1213) Deadlock found when trying to get lockconcurrent transactions locking rows in different ordersretry the transaction; keep lock ordering consistent
(1205) Lock wait timeout exceededlong transaction holding locksfind it in SHOW ENGINE INNODB STATUS; shorten transactions
(1071) Specified key was too longindex on long utf8mb4 columns on old row formatsuse DYNAMIC row format (default on MySQL 8) or shorter columns
reading MySQL errors from DjangoErrorUsual causeIncorrect string value: \xF0\x9F…table or connectionis utf8, not utf8mb4MySQL server has gone awayconnection idle pastwait_timeout; lower CONN_MAX_AGEData too long for columnstrict mode working;validate length in the formDeadlock found when trying to get lockconcurrent writes;retry the transactionTwo of the four disappear with the settings from steps 2 to 4django-deployment.com
Data too long is strict mode doing its job: the alternative was silent truncation.

Finding slow queries

Enable the slow query log temporarily to find queries that need indexes:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
SHOW VARIABLES LIKE 'slow_query_log_file';

Then analyze the file with mysqldumpslow:

sudo mysqldumpslow -s t -t 10 /var/lib/mysql/myhost-slow.log

Match the reported queries to Django code by looking at table names and filter columns, then add indexes with a migration (Meta.indexes) and verify with EXPLAIN. Turn the slow log threshold back up or disable it after investigating on busy servers.

Handling deadlocks in application code

InnoDB resolves deadlocks by rolling back one transaction. For operations that can safely be retried, wrap them:

import time

from django.db import OperationalError, transaction

DEADLOCK_CODES = {1213}


def run_with_retry(func, attempts=3):
    for attempt in range(1, attempts + 1):
        try:
            with transaction.atomic():
                return func()
        except OperationalError as exc:
            if exc.args and exc.args[0] in DEADLOCK_CODES and attempt < attempts:
                time.sleep(0.05 * attempt)
                continue
            raise

Retrying only helps when the whole unit of work is inside the atomic block and has no external side effects, such as sending email, before it commits.

Migrating from MySQL to PostgreSQL later

If you expect to move databases, avoid MySQL-specific SQL in raw queries, rely on the ORM for case-insensitive lookups (iexact) instead of collation behavior, and keep data dumps portable (dumpdata works for small databases; larger ones need dedicated migration tools).

Next steps

FAQ

Which MySQL driver should Django use?

mysqlclient, which Django recommends and tests against. It requires MySQL client development headers to build when no prebuilt wheel is available.

Why does Django warn about MySQL strict mode?

Without strict mode, MySQL may truncate or coerce invalid values and only issue a warning, which leads to silent data loss. Set sql_mode to include STRICT_TRANS_TABLES.

How do I fix "MySQL server has gone away" in Django?

Make sure CONN_MAX_AGE is lower than the server's wait_timeout, enable CONN_HEALTH_CHECKS, and check max_allowed_packet if the error happens on large queries.

Should I use utf8 or utf8mb4 for Django on MySQL?

Always utf8mb4, for the database, tables, and connection. The older utf8 character set cannot store 4-byte characters such as emoji.

Keep going