Skip to main content

Django: Database Transactions

Django: Database Transactions

Database transactions in Django ensure data integrity by grouping multiple database operations into a single, atomic unit that either fully succeeds or fails. Managed through Django’s database API and integrated with its Object-Relational Mapping (ORM), transactions provide consistency and reliability for operations like financial transfers, order processing, or multi-model updates. This tutorial explores Django database transactions, covering transaction management, atomicity, savepoints, and practical applications for robust web applications.


01. Why Use Database Transactions?

Transactions guarantee the ACID properties (Atomicity, Consistency, Isolation, Durability), preventing partial updates that could lead to data corruption. In Django, transactions are essential for scenarios where multiple database operations must be executed as a single unit, such as transferring funds between accounts or updating related models. Django’s transaction management simplifies this process, ensuring reliability without complex manual handling.

Example: Basic Transaction with Atomic

# myapp/models.py
from django.db import models

class Account(models.Model):
    owner = models.CharField(max_length=50)
    balance = models.DecimalField(max_digits=10, decimal_places=2)

    def __str__(self):
        return f"{self.owner} (${self.balance})"

# Transaction in view or shell
from django.db import transaction
from myapp.models import Account

def transfer_funds(from_account_id, to_account_id, amount):
    with transaction.atomic():
        from_account = Account.objects.get(id=from_account_id)
        to_account = Account.objects.get(id=to_account_id)
        if from_account.balance >= amount:
            from_account.balance -= amount
            to_account.balance += amount
            from_account.save()
            to_account.save()
        else:
            raise ValueError("Insufficient funds")

# Example usage
try:
    transfer_funds(1, 2, 100)
    print("Transfer successful")
except ValueError as e:
    print(e)

Output: (Assuming sample data)

Transfer successful

Explanation:

  • transaction.atomic() - Ensures all operations (deducting and adding funds) complete or none are applied.
  • If an error occurs (e.g., insufficient funds), the transaction rolls back, leaving the database unchanged.

02. Key Transaction Concepts and Tools

Django provides flexible tools for managing transactions, from automatic handling to fine-grained control with savepoints. The table below summarizes key transaction features and their applications:

Feature Description Use Case
atomic() Ensures atomicity for a block of code Group multiple model updates
Savepoints Partial rollback within a transaction Handle nested operations
on_commit() Execute code after transaction commits Trigger notifications or tasks
Manual Transaction Control Explicit commit or rollback Custom transaction logic


2.1 Using transaction.atomic()

Example: Atomic Transaction with Multiple Models

# myapp/models.py
from django.db import models

class Customer(models.Model):
    name = models.CharField(max_length=50)

    def __str__(self):
        return self.name

class Order(models.Model):
    customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
    total = models.DecimalField(max_digits=10, decimal_places=2)

    def __str__(self):
        return f"Order {self.id}"

class OrderItem(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE)
    product_name = models.CharField(max_length=100)
    quantity = models.PositiveIntegerField()

    def __str__(self):
        return f"{self.product_name} (x{self.quantity})"

# Create order with items
from django.db import transaction

def create_order(customer_id, items):
    with transaction.atomic():
        customer = Customer.objects.get(id=customer_id)
        order = Order.objects.create(customer=customer, total=0)
        total = 0
        for item in items:
            OrderItem.objects.create(
                order=order,
                product_name=item['name'],
                quantity=item['quantity']
            )
            total += item['price'] * item['quantity']
        order.total = total
        order.save()
    return order

# Example usage
items = [
    {'name': 'Laptop', 'quantity': 1, 'price': 999.99},
    {'name': 'Mouse', 'quantity': 2, 'price': 29.99}
]
try:
    order = create_order(1, items)
    print(f"Order created: {order.id}, Total: {order.total}")
except Exception as e:
    print(f"Error: {e}")

Output: (Assuming sample data)

Order created: 1, Total: 1059.97

Explanation:

  • atomic() - Wraps order and item creation in a transaction.
  • If any operation fails (e.g., invalid customer), all changes are rolled back.

2.2 Using Savepoints

Example: Partial Rollback with Savepoints

from django.db import transaction
from myapp.models import Account

def complex_transfer(from_account_id, to_account_id, amount, bonus_amount):
    with transaction.atomic():
        from_account = Account.objects.get(id=from_account_id)
        to_account = Account.objects.get(id=to_account_id)
        
        # Deduct from sender
        if from_account.balance < amount:
            raise ValueError("Insufficient funds")
        from_account.balance -= amount
        from_account.save()
        
        # Savepoint for bonus
        savepoint_id = transaction.savepoint()
        try:
            to_account.balance += amount + bonus_amount
            if bonus_amount > 100:  # Arbitrary condition
                raise ValueError("Bonus too high")
            to_account.save()
            transaction.savepoint_commit(savepoint_id)
        except ValueError:
            transaction.savepoint_rollback(savepoint_id)
            to_account.balance += amount  # Apply only base amount
            to_account.save()
    
    print("Transfer completed")

# Example usage
try:
    complex_transfer(1, 2, 100, 150)  # Bonus too high
except ValueError as e:
    print(f"Error: {e}")

Output: (Assuming sample data)

Transfer completed

Explanation:

  • savepoint() - Creates a checkpoint within the transaction.
  • savepoint_rollback() - Reverts to the savepoint if the bonus fails, allowing partial success.

2.3 Using on_commit()

Example: Post-Commit Actions

from django.db import transaction
from myapp.models import Order

def send_order_confirmation(order_id):
    print(f"Sending confirmation for Order {order_id}")

def create_order_with_confirmation(customer_id, total):
    with transaction.atomic():
        order = Order.objects.create(customer_id=customer_id, total=total)
        transaction.on_commit(lambda: send_order_confirmation(order.id))
    return order

# Example usage
order = create_order_with_confirmation(1, 500.00)
print(f"Order created: {order.id}")

Output: (Assuming sample data)

Order created: 1
Sending confirmation for Order 1

Explanation:

  • on_commit() - Schedules a function to run after the transaction commits successfully.
  • Ideal for non-critical tasks like sending emails or logging.

2.4 Incorrect Transaction Usage

Example: Missing Transaction for Related Updates

from myapp.models import Account

# Incorrect: No transaction
def unsafe_transfer(from_account_id, to_account_id, amount):
    from_account = Account.objects.get(id=from_account_id)
    to_account = Account.objects.get(id=to_account_id)
    if from_account.balance >= amount:
        from_account.balance -= amount
        from_account.save()
        # Possible crash or interruption here
        to_account.balance += amount
        to_account.save()
    else:
        raise ValueError("Insufficient funds")

# Example usage
try:
    unsafe_transfer(1, 2, 100)
except Exception as e:
    print(f"Error: {e}")

Output: (If interrupted after first save)

Database inconsistency: Funds deducted but not credited.

Explanation:

  • Without atomic(), a failure after the first save leaves the database inconsistent.
  • Solution: Wrap operations in transaction.atomic().

03. Effective Usage

3.1 Recommended Practices

  • Use atomic() for operations requiring data consistency.

Example: Comprehensive Transaction with Error Handling

from django.db import transaction
from myapp.models import Customer, Order, OrderItem

def process_order(customer_id, items):
    try:
        with transaction.atomic():
            customer = Customer.objects.select_for_update().get(id=customer_id)  # Lock row
            order = Order.objects.create(customer=customer, total=0)
            total = 0
            for item in items:
                if item['quantity'] <= 0:
                    raise ValueError("Invalid quantity")
                OrderItem.objects.create(
                    order=order,
                    product_name=item['name'],
                    quantity=item['quantity']
                )
                total += item['price'] * item['quantity']
            order.total = total
            order.save()
            transaction.on_commit(lambda: print(f"Notify user for Order {order.id}"))
        return order
    except Exception as e:
        print(f"Order creation failed: {e}")
        raise

# Example usage
items = [
    {'name': 'Laptop', 'quantity': 1, 'price': 999.99},
    {'name': 'Mouse', 'quantity': 2, 'price': 29.99}
]
order = process_order(1, items)
print(f"Order created: {order.id}, Total: {order.total}")

Output: (Assuming sample data)

Notify user for Order 1
Order created: 1, Total: 1059.97
  • select_for_update() - Locks the customer row to prevent concurrent modifications.
  • on_commit() - Triggers a notification after success.
  • Exception handling ensures clear error reporting.

3.2 Practices to Avoid

  • Avoid long-running transactions to prevent database locks.

Example: Long-Running Transaction

from django.db import transaction
import time

# Incorrect: Long-running transaction
def slow_operation(customer_id):
    with transaction.atomic():
        customer = Customer.objects.select_for_update().get(id=customer_id)
        time.sleep(10)  # Simulate slow operation
        customer.name = "Updated Name"
        customer.save()

# Example usage
slow_operation(1)

Output:

Database lock on customer row for 10 seconds, risking timeouts or deadlocks.
  • Long transactions with select_for_update() can block other operations.
  • Solution: Minimize transaction scope and avoid non-database operations inside atomic().

04. Common Use Cases

4.1 E-Commerce Order Processing

Ensure atomic order creation with inventory updates.

Example: Order with Inventory Update

from django.db import transaction
from myapp.models import Product, Order, OrderItem

def place_order(customer_id, items):
    with transaction.atomic():
        order = Order.objects.create(customer_id=customer_id, total=0)
        total = 0
        for item in items:
            product = Product.objects.select_for_update().get(id=item['product_id'])
            if product.stock < item['quantity']:
                raise ValueError(f"Insufficient stock for {product.name}")
            product.stock -= item['quantity']
            product.save()
            OrderItem.objects.create(
                order=order,
                product_name=product.name,
                quantity=item['quantity']
            )
            total += item['price'] * item['quantity']
        order.total = total
        order.save()
        transaction.on_commit(lambda: print(f"Send confirmation for Order {order.id}"))
    return order

# Example usage
items = [
    {'product_id': 1, 'quantity': 1, 'price': 999.99},
    {'product_id': 2, 'quantity': 2, 'price': 29.99}
]
try:
    order = place_order(1, items)
    print(f"Order placed: {order.id}, Total: {order.total}")
except ValueError as e:
    print(f"Error: {e}")

Output: (Assuming sample data)

Send confirmation for Order 1
Order placed: 1, Total: 1059.97

Explanation:

  • select_for_update() - Locks products to prevent concurrent stock changes.
  • Transaction ensures stock and order updates are atomic.

4.2 Financial Account Transfers

Manage secure fund transfers between accounts.

Example: Secure Fund Transfer

from django.db import transaction
from myapp.models import Account

def transfer_with_audit(from_account_id, to_account_id, amount):
    with transaction.atomic():
        from_account = Account.objects.select_for_update().get(id=from_account_id)
        to_account = Account.objects.select_for_update().get(id=to_account_id)
        if from_account.balance < amount:
            raise ValueError("Insufficient funds")
        
        from_account.balance -= amount
        to_account.balance += amount
        from_account.save()
        to_account.save()
        
        # Log transaction
        transaction.on_commit(
            lambda: print(f"Logged transfer of {amount} from {from_account.owner} to {to_account.owner}")
        )

# Example usage
try:
    transfer_with_audit(1, 2, 200)
    print("Transfer successful")
except ValueError as e:
    print(f"Error: {e}")

Output: (Assuming sample data)

Logged transfer of 200 from Alice to Bob
Transfer successful

Explanation:

  • Locks both accounts to prevent race conditions.
  • on_commit() - Logs the transfer only after success.

Conclusion

Django’s transaction management provides a robust framework for ensuring data integrity in web applications. Key takeaways:

  • Use transaction.atomic() to guarantee atomicity for related operations.
  • Leverage savepoints for partial rollbacks in complex transactions.
  • Schedule post-commit actions with on_commit() for non-critical tasks.
  • Avoid long-running transactions and ensure proper error handling.

With Django’s transaction tools, you can build reliable, consistent data operations for scalable and secure web applications!

Comments