Database Interaction: Python Connectors and CRUD

intermediate recent 9 min read updated 17 Aug 2026
On this page 9

Database Connectors: Why Python Needs Them

Python applications cannot directly communicate with a relational database management system (RDBMS). Databases like PostgreSQL, MySQL, or SQL Server implement their own proprietary network protocols and APIs for interaction. These protocols are distinct from Python’s standard library or native data types.

Database connectors, often called drivers, bridge this communication gap. A connector is a library that translates Python’s function calls and data structures into the database’s native protocol. It also translates the database’s responses back into Python objects, ensuring data consistency and type mapping.

Python’s database interaction is standardized by PEP 249, the Python Database API Specification v2.0. This specification defines a common interface for database modules. Any connector adhering to PEP 249 provides a consistent set of methods for connecting, executing queries, fetching results, and handling transactions, regardless of the underlying database.

This standardization is essential because it allows developers to write database-agnostic code. An application designed to work with PostgreSQL using psycopg2 can often be adapted to MySQL using mysql-connector-python with minimal changes to the core database interaction logic, provided the SQL syntax remains compatible. This consistency simplifies development and maintenance.

Several common Python libraries implement the DB-API 2.0 standard for various RDBMS:

  • PostgreSQL: psycopg2 or psycopg (for newer PostgreSQL versions and Python 3.7+)
  • MySQL: mysql-connector-python (official Oracle connector) or PyMySQL
  • SQLite: sqlite3 (built-in to the Python standard library)
  • SQL Server: pyodbc (requires an ODBC driver installed on the system)

These connectors are typically installed via pip. For example:

pip install psycopg2-binary
pip install mysql-connector-python
# No installation needed for sqlite3
import sqlite3

Once installed, these libraries expose similar interfaces for establishing connections and executing SQL commands, as defined by PEP 249.

Python sqlite3: How to Connect and Query

The sqlite3 module provides a lightweight, file-based relational database interface directly within Python. It requires no separate server process, making it suitable for local development and small-scale applications.

To interact with a SQLite database, establish a connection using sqlite3.connect(). This function takes a database file path as an argument. Providing :memory: creates a temporary, in-memory database that is lost when the connection closes. For persistent storage, specify a file name.

import sqlite3

# Connect to a database file. If it doesn't exist, it will be created.
conn = sqlite3.connect('example.db')

# For an in-memory database (data lost on disconnect):
# conn = sqlite3.connect(':memory:')

Database operations, such as executing SQL commands, are performed through a cursor object. Obtain a cursor from the connection object.

cursor = conn.cursor()

Create a table using a CREATE TABLE SQL statement. Execute this statement via the cursor’s execute() method.

cursor.execute('''
    CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY,
        name TEXT NOT NULL,
        email TEXT UNIQUE
    )
''')

Insert data into the table using the INSERT INTO statement. Parameterized queries, using ? as placeholders, prevent SQL injection vulnerabilities and handle data types correctly. Pass a tuple of values to the execute() method.

cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)", ('Alice', '[email protected]'))
cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)", ('Bob', '[email protected]'))

After modifying the database (e.g., INSERT, UPDATE, DELETE), commit the changes to make them permanent. Uncommitted changes are not saved.

conn.commit()

Fetch data using a SELECT statement. The execute() method runs the query, and results are retrieved using fetchone() for a single row or fetchall() for all rows. Each row is returned as a tuple.

cursor.execute("SELECT id, name, email FROM users WHERE name = ?", ('Alice',))
user = cursor.fetchone()
print(f"Fetched user: {user}")

cursor.execute("SELECT name, email FROM users")
all_users = cursor.fetchall()
print(f"All users: {all_users}")
Fetched user: (1, 'Alice', '[email protected]')
All users: [('Alice', '[email protected]'), ('Bob', '[email protected]')]

Always close the cursor and connection when database operations are complete to release resources.

cursor.close()
conn.close()

Data Manipulation: Python CRUD Operations

Applications require interaction with stored data. The fundamental operations for managing records in a relational database are Create, Read, Update, and Delete (CRUD). Implementing these operations securely from Python involves using parameterized queries. This approach separates the SQL command from the data, preventing SQL injection vulnerabilities. Parameterized queries prevent SQL injection vulnerabilities and correctly handle special characters in data.

Assuming an active sqlite3 connection and cursor (conn, cursor), we will demonstrate these operations on a users table with id, name, and email columns.

Create (INSERT)

To add new records, use the INSERT INTO statement. Pass data as a tuple or list to the execute() method, where placeholders (? for sqlite3) mark where values should be substituted. After executing an INSERT, conn.commit() saves the changes to the database.

import sqlite3

# Assume conn and cursor are established
conn = sqlite3.connect('app.db')
cursor = conn.cursor()

# Ensure table exists for demonstration
cursor.execute('''
    CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY,
        name TEXT NOT NULL,
        email TEXT UNIQUE NOT NULL
    )
''')
conn.commit()

user_name = "Alice Smith"
user_email = "[email protected]"
cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)", (user_name, user_email))
conn.commit()
print(f"Inserted user with ID: {cursor.lastrowid}")

Read (SELECT)

Retrieving data uses the SELECT statement. To fetch a single record, use cursor.fetchone(). This returns one row as a tuple or None if no matching record exists. For multiple records, cursor.fetchall() retrieves all remaining rows as a list of tuples.

# Select all users
cursor.execute("SELECT id, name, email FROM users")
all_users = cursor.fetchall()
print("\nAll users:")
for user in all_users:
    print(user)

# Select a specific user by ID using a parameterized query
user_id_to_find = 1
cursor.execute("SELECT id, name, email FROM users WHERE id = ?", (user_id_to_find,))
single_user = cursor.fetchone()
if single_user:
    print(f"\nUser with ID {user_id_to_find}: {single_user}")
else:
    print(f"\nNo user found with ID {user_id_to_find}.")

Update (UPDATE)

Modifying existing records is done with the UPDATE statement. The SET clause specifies the columns to change, and the WHERE clause identifies which records to update. Omitting the WHERE clause will update all records in the table, which is rarely the desired outcome.

new_email = "[email protected]"
user_id_to_update = 1
cursor.execute("UPDATE users SET email = ? WHERE id = ?", (new_email, user_id_to_update))
conn.commit()
print(f"\nUpdated user ID {user_id_to_update}'s email.")

Delete (DELETE)

To remove records, use the DELETE FROM statement. Similar to UPDATE, a WHERE clause is crucial to target specific records. Without it, all records in the table will be deleted.

# Insert another user to demonstrate deletion
cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)", ("Bob Johnson", "[email protected]"))
conn.commit()
user_id_to_delete = cursor.lastrowid # Get the ID of the newly inserted user

cursor.execute("DELETE FROM users WHERE id = ?", (user_id_to_delete,))
conn.commit()
print(f"\nDeleted user ID {user_id_to_delete}.")

conn.close()

Transactions: Ensuring Database Integrity

Database operations often involve multiple steps that must complete successfully as a single logical unit. For example, updating an order status and simultaneously deducting inventory requires both actions to succeed or fail together. If one operation succeeds and the other fails, data integrity is compromised, leaving the database in an inconsistent state.

A database transaction groups a sequence of operations into an atomic unit. This ensures an “all or nothing” outcome: either every operation within the transaction completes successfully and is permanently recorded (committed), or if any part fails, all changes are undone (rolled back). This atomicity is crucial for maintaining data consistency, preventing the database from reaching an invalid intermediate state.

Transactions also provide isolation, meaning concurrent transactions do not interfere with each other’s intermediate states. Once committed, changes are durable, persisting even through system failures. Python database connectors manage these transactions via the connection object. Most connectors, such as sqlite3 and psycopg2, default to manual commit mode. This requires explicit calls to connection.commit() to save changes or connection.rollback() to discard them if an error occurs.

The following example demonstrates transaction management for inserting related data into two tables. If the second insert fails, the first must also be undone to preserve integrity.

import sqlite3

# Assume a database 'app.db' with tables 'users' and 'profiles'.
# For demonstration, ensure these tables exist or create them:
# CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
# CREATE TABLE profiles (user_id INTEGER UNIQUE, bio TEXT, FOREIGN KEY (user_id) REFERENCES users(id));

conn = None
try:
    conn = sqlite3.connect('app.db')
    cursor = conn.cursor()

    # Insert a new user. This change is not yet permanent.
    cursor.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
    user_id = cursor.lastrowid # Get the ID of the newly inserted user

    # Simulate an error: attempting to insert into a non-existent column.
    # This will cause an sqlite3.OperationalError and trigger the rollback.
    cursor.execute("INSERT INTO profiles (user_id, non_existent_column) VALUES (?, ?)", (user_id, "Engineer"))

    conn.commit() # This line is only reached if all operations succeed
    print(f"User {user_id} and profile committed successfully.")

except sqlite3.Error as e:
    if conn:
        conn.rollback() # Discard all changes if an error occurred
    print(f"Transaction failed: {e}. Changes rolled back.")

finally:
    if conn:
        conn.close() # Always close the connection

In this example, the INSERT into users is followed by an INSERT into profiles. If the profiles insert fails—for instance, due to a non-existent column or a constraint violation—the except block catches the sqlite3.Error. The conn.rollback() call is then executed, reverting the users table to its state before the transaction began, ensuring no partial data remains. This guarantees that either both inserts succeed, or neither does. The finally block is necessary; it ensures the database connection is always closed, releasing resources regardless of whether the transaction succeeded or failed. This try...except...finally pattern for transaction management is a standard and robust approach for maintaining data integrity in production systems.

Relational Data: Building a Python Interface

To manage relational data efficiently from Python, a structured interface simplifies interaction beyond raw SQL statements. This approach encapsulates database operations, allowing application code to interact with data through Python objects and methods. We will build a simple ProductManager class to interact with a products table.

Consider a products table with id, name, price, and stock columns. The sqlite3 module, included with Python, provides a direct way to connect to SQLite databases. The ProductManager class constructor establishes a connection to a specified database file and creates the products table if it does not exist.

import sqlite3

class ProductManager:
    def __init__(self, db_path="inventory.db"):
        self.conn = sqlite3.connect(db_path)
        self.cursor = self.conn.cursor()
        self._create_table()

    def _create_table(self):
        self.cursor.execute("""
            CREATE TABLE IF NOT EXISTS products (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                price REAL NOT NULL,
                stock INTEGER NOT NULL
            )
        """)
        self.conn.commit()

The _create_table method ensures the database schema is present upon initialization. It uses CREATE TABLE IF NOT EXISTS to prevent errors on subsequent runs. The conn.commit() call saves these schema changes permanently.

Data insertion uses the insert_product method. This method takes product details and uses a parameterized INSERT query. Parameterized queries are crucial for preventing SQL injection vulnerabilities and correctly handling special characters in data.

    def insert_product(self, name: str, price: float, stock: int) -> int:
        self.cursor.execute("INSERT INTO products (name, price, stock) VALUES (?, ?, ?)", (name, price, stock))
        self.conn.commit()
        return self.cursor.lastrowid

Retrieving data is handled by get_product and list_products. get_product fetches a single record by its ID, returning a tuple. list_products retrieves all entries, providing a full view of the inventory.

    def get_product(self, product_id: int) -> tuple | None:
        self.cursor.execute("SELECT id, name, price, stock FROM products WHERE id = ?", (product_id,))
        return self.cursor.fetchone()

    def list_products(self) -> list[tuple]:
        self.cursor.execute("SELECT id, name, price, stock FROM products")
        return self.cursor.fetchall()

To modify or remove data, update_product_stock and delete_product methods are available. Both accept a product_id and execute their respective UPDATE or DELETE SQL commands. Each modification requires a conn.commit() to persist changes to the database.

    def update_product_stock(self, product_id: int, new_stock: int) -> bool:
        self.cursor.execute("UPDATE products SET stock = ? WHERE id = ?", (new_stock, product_id))
        self.conn.commit()
        return self.cursor.rowcount > 0

    def delete_product(self, product_id: int) -> bool:
        self.cursor.execute("DELETE FROM products WHERE id = ?", (product_id,))
        self.conn.commit()
        return self.cursor.rowcount > 0

    def close(self):
        self.conn.close()

Using this interface, application code operates on ProductManager objects, abstracting SQL details.

manager = ProductManager("inventory.db")
product_id = manager.insert_product("Keyboard", 75.00, 100)
print(f"Product added with ID: {product_id}") # Output: Product added with ID: 1
print(f"Retrieved: {manager.get_product(product_id)}") # Output: Retrieved: (1, 'Keyboard', 75.0, 100)
manager.update_product_stock(product_id, 90)
print(f"Updated stock: {manager.get_product(product_id)[3]}") # Output: Updated stock: 90
manager.close()

This structured approach improves code readability and maintainability compared to embedding raw SQL directly throughout an application. It also centralizes database logic, making schema changes or database migration simpler to manage.