Skip to content

PyMySQL Security Best Practices

1. Preventing SQL Injection

SQL injection is the most critical database vulnerability. PyMySQL provides safe parameterization via its parameter substitution — never bypass it.

Use Parameterized Queries

python
import pymysql

# Safe — parameter is bound, never interpolated into SQL
with connection.cursor() as cursor:
    cursor.execute(
        "SELECT id, username, email, role FROM users WHERE email = %s",
        (user_input,)
    )
    result = cursor.fetchall()

# Also safe — named parameters with a dict
with connection.cursor() as cursor:
    cursor.execute(
        "SELECT * FROM users WHERE email = %(email)s",
        {"email": user_input}
    )

Never Interpolate User Input

python
# DANGEROUS — direct string formatting
cursor.execute(f"SELECT * FROM users WHERE email = '{user_input}'")

# DANGEROUS — string concatenation
query = "SELECT * FROM users WHERE name = '" + name + "'"
cursor.execute(query)

# DANGEROUS — %-formatting the string yourself
cursor.execute("SELECT * FROM users WHERE name = '%s'" % name)

Never Use Raw String Formatting

Even though PyMySQL uses %s placeholders, you must pass the values as the second argument to execute(), never format them into the query string yourself. PyMySQL's driver handles escaping — manual formatting bypasses all protections.

2. Securing the Database Connection

Use Environment Variables for Credentials

Never hardcode database credentials in source code.

Install python-dotenv

bash
pip install python-dotenv

Create a .env file

Store credentials in a .env file at the root of your project. This file is for local development only — use a proper secrets manager (Vault, AWS Secrets Manager, etc.) in production.

bash
# .env
DB_HOST=localhost
DB_USER=appuser
DB_PASSWORD=s3cur3pass
DB_NAME=mydb
DB_PORT=3306

Keep .env out of version control

Add it to .gitignore immediately. Committing credentials is one of the most common causes of secret leaks.

bash
# .gitignore
.env

Provide a .env.example with placeholder values so collaborators know which variables are required:

bash
# .env.example — safe to commit
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_PORT=3306

Load the .env file

Call load_dotenv() once at application startup, before any code that reads environment variables. It populates os.environ from the .env file, but does not override variables that are already set — so real environment variables in production take precedence automatically.

python
from dotenv import load_dotenv
import os
import pymysql

load_dotenv()  # Call once, at startup

connection = pymysql.connect(
    host=os.environ["DB_HOST"],
    user=os.environ["DB_USER"],
    password=os.environ["DB_PASSWORD"],
    database=os.environ["DB_NAME"],
    port=int(os.environ.get("DB_PORT", 3306)),
)

os.environ[key] vs os.environ.get(key)

Use os.environ["KEY"] (bracket syntax) for required variables. It raises a KeyError immediately at startup if the variable is missing, making misconfigurations obvious. Use os.environ.get("KEY", default) only for truly optional variables with safe defaults — such as DB_PORT.

Use SSL/TLS for Remote Connections

python
import ssl

ssl_context = ssl.create_default_context(cafile="/path/to/ca-cert.pem")
ssl_context.load_cert_chain(
    certfile="/path/to/client-cert.pem",
    keyfile="/path/to/client-key.pem"
)

connection = pymysql.connect(
    host=os.environ["DB_HOST"],
    user=os.environ["DB_USER"],
    password=os.environ["DB_PASSWORD"],
    database=os.environ["DB_NAME"],
    ssl=ssl_context,
)

Verify Server Certificates

Always verify the server's SSL certificate in production. Use ssl.create_default_context() (which verifies by default) rather than ssl_verify_cert=False, to prevent man-in-the-middle attacks.

3. Principle of Least Privilege

Create dedicated database users with only the permissions your application actually needs.

sql
-- Create a restricted application user
CREATE USER 'appuser'@'%' IDENTIFIED BY 'strong_password';

-- Grant only what is needed
GRANT SELECT, INSERT, UPDATE, DELETE ON mydb.* TO 'appuser'@'%';

-- Never grant these to the application user:
-- GRANT ALL PRIVILEGES ...
-- GRANT DROP, ALTER, CREATE ...
FLUSH PRIVILEGES;
RolePermissions
App (read/write)SELECT, INSERT, UPDATE, DELETE
App (read-only)SELECT
Migrations onlyALTER, CREATE, DROP
AdminAll (never used by app code)

4. Connection Management

Always Close Connections and Cursors

Use context managers to guarantee cursors and connections are closed.

python
import pymysql

# Cursor closes automatically on __exit__
with connection.cursor() as cursor:
    cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
    user = cursor.fetchone()

# Full connection lifecycle with context manager
connection = pymysql.connect(**db_config)
try:
    with connection.cursor() as cursor:
        cursor.execute("INSERT INTO events (name) VALUES (%s)", ("login",))
    connection.commit()
except pymysql.MySQLError as e:
    connection.rollback()
    raise
finally:
    connection.close()

Use a Connection Pool for Long-Running Applications

PyMySQL itself is not thread-safe and does not include a pool. Use a pooling library such as DBUtils for multi-threaded applications.

python
from dbutils.pooled_db import PooledDB
import pymysql

pool = PooledDB(
    creator=pymysql,
    maxconnections=10,   # Hard cap on total connections
    mincached=2,         # Connections kept open when idle
    maxcached=5,         # Max idle connections in pool
    blocking=True,       # Block instead of raising when pool is full
    host=os.environ["DB_HOST"],
    user=os.environ["DB_USER"],
    password=os.environ["DB_PASSWORD"],
    database=os.environ["DB_NAME"],
    charset="utf8mb4",
)

# Borrow a connection from the pool
connection = pool.connection()
try:
    with connection.cursor() as cursor:
        cursor.execute("SELECT 1")
finally:
    connection.close()  # Returns connection to pool, does not disconnect

Connection Leaks

Unreturned connections exhaust the pool and cause application downtime. Always use try/finally or a context manager to guarantee the connection is returned.

5. Input Validation & Sanitization

PyMySQL parameterization prevents injection, but you still need to validate inputs before they reach the database.

python
from pydantic import BaseModel, EmailStr, constr

class UserCreate(BaseModel):
    username: constr(min_length=3, max_length=50, pattern=r"^[a-zA-Z0-9_]+$")
    email: EmailStr
    age: int

def create_user(data: UserCreate, connection):
    with connection.cursor() as cursor:
        cursor.execute(
            "INSERT INTO users (username, email, age) VALUES (%s, %s, %s)",
            (data.username, data.email, data.age),
        )
    connection.commit()

Validate before constructing any database operation:

  • Enforce expected types and lengths
  • Reject unexpected characters using allow-lists
  • Validate business rules (e.g. age > 0) at the model level

6. Password Storage

Never store plaintext passwords. Use a strong adaptive hashing algorithm.

python
import bcrypt

def hash_password(plain: str) -> str:
    return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()

def verify_password(plain: str, hashed: str) -> bool:
    return bcrypt.checkpw(plain.encode(), hashed.encode())

# Storing
with connection.cursor() as cursor:
    cursor.execute(
        "INSERT INTO users (username, password_hash) VALUES (%s, %s)",
        (username, hash_password(raw_password)),
    )
connection.commit()

# Verifying
with connection.cursor() as cursor:
    cursor.execute(
        "SELECT password_hash FROM users WHERE username = %s", (username,)
    )
    row = cursor.fetchone()

if not row or not verify_password(submitted_password, row["password_hash"]):
    raise ValueError("Invalid credentials")

Recommended Algorithms

Use bcrypt, Argon2, or scrypt. Never use MD5, SHA-1, or unsalted SHA-256 for passwords.

7. Protecting Against Mass Assignment

When mapping request payloads to database writes, be explicit about which fields are allowed.

python
# DANGEROUS — attacker could inject is_admin=True
def update_user(user_id: int, payload: dict, connection):
    columns = ", ".join(f"{k} = %s" for k in payload)
    values = list(payload.values()) + [user_id]
    with connection.cursor() as cursor:
        cursor.execute(f"UPDATE users SET {columns} WHERE id = %s", values)
    connection.commit()  # Never do this — columns come from user input

# Safe — only update allowed fields
ALLOWED_FIELDS = {"username", "email", "bio"}

def update_user(user_id: int, payload: dict, connection):
    updates = {k: v for k, v in payload.items() if k in ALLOWED_FIELDS}
    if not updates:
        return
    columns = ", ".join(f"{k} = %s" for k in updates)
    values = list(updates.values()) + [user_id]
    with connection.cursor() as cursor:
        cursor.execute(f"UPDATE users SET {columns} WHERE id = %s", values)
    connection.commit()

8. Error Handling & Information Disclosure

Never expose raw database errors to users — they can leak schema details, table names, or stack traces useful to an attacker.

python
import logging
import pymysql

logger = logging.getLogger(__name__)

def get_user(user_id: int, connection):
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                "SELECT id, username, email FROM users WHERE id = %s", (user_id,)
            )
            return cursor.fetchone()
    except pymysql.MySQLError as e:
        logger.error("Database error fetching user %s: %s", user_id, e)
        raise RuntimeError("An internal error occurred") from None

Log Internally, Surface Generically

Log the full exception server-side for debugging. Return only a generic message to the client. Never include table names, column names, or SQL in API responses.

9. Auditing & Logging

Track sensitive operations for security auditing. With PyMySQL you can wrap your cursor calls in a helper that logs before executing.

python
import logging

logger = logging.getLogger(__name__)

def audited_execute(cursor, query, params=None, *, label="query"):
    logger.debug("[SQL:%s] %s | params=%s", label, query, params)
    cursor.execute(query, params)

# Usage
with connection.cursor() as cursor:
    audited_execute(
        cursor,
        "DELETE FROM sessions WHERE user_id = %s",
        (user_id,),
        label="logout",
    )
connection.commit()

Consider logging: login attempts, privilege changes, bulk deletes/updates, and access to sensitive tables.

Quick Reference Checklist

  • Using %s / %(name)s bound parameters — no raw string interpolation
  • Credentials stored in environment variables; .env in .gitignore, .env.example committed
  • SSL/TLS enabled for remote connections
  • Database user has least-privilege permissions
  • Connections always closed via try/finally or context managers
  • Connection pool configured for multi-threaded applications
  • All inputs validated before hitting the database
  • Passwords hashed with bcrypt/Argon2
  • Mass assignment prevented via field allowlists
  • Database errors caught and not exposed to users

Lapland University of Applied Sciences

© 2026 Juha Petäjäjärvi

© 2026 Juha Petäjäjärvi