Thought leadership from the most innovative tech companies, all in one place.

Understanding Django’s Transaction Atomic

Django’s transaction management provides a robust mechanism to handle database transactions. In this article, we’ll explore the transaction atomic feature and how it ensures data consistency and integrity in Django applications. We’ll use a sample Django project with user accounts and demonstrate how to transfer funds between two accounts while maintaining transactional integrity.

What are Transactions?

Before diving into transaction atomic, let’s understand the concept of transactions. In a database context, a transaction represents a logical unit of work that either succeeds as a whole or fails completely, ensuring data consistency. A transaction typically consists of multiple database operations, such as inserts, updates, and deletions.

Django’s Transaction Atomic

Django provides the transaction.atomic decorator, which ensures that a block of database operations is executed as an atomic transaction. An atomic transaction guarantees that all operations within the block are treated as a single unit. If any operation fails, the entire transaction is rolled back, undoing all changes made within the block.

To demonstrate the transaction atomic feature, we’ll create a function called transfer that handles the transfer of funds between two accounts. This function uses the transaction.atomic decorator to ensure the transfer operation is atomic.

class AccountViewSet(viewsets.ModelViewSet):
    queryset = Account.objects.all()
    serializer_class = AccountSerializer

    def transfer(self, request):
        try:
            user_a = request.POST.get("user_a")
            user_b = request.POST.get("user_b")
            amount = request.POST.get("amount")

            with transaction.atomic():
                user_a_obj = Account.objects.get(user=user_a)
                user_a_obj.balance -= int(amount)
                user_a_obj.save()

                # raise an exception here to demostrate rollback
                raise Exception

                user_b_obj = Account.objects.get(user=user_b)
                user_b_obj.balance += int(amount)
                user_b_obj.save()

                return Response(
                    {"status": "success", "message": "Your amount is transfered."}
                )

        except Exception as e:
            print(e)
            return Response({"status": "failed", "message": "Something went wrong."})

We encapsulate the entire transfer operation within a transaction.atomic block. During the execution of a transaction, two critical concepts come into play: commit and rollback. A commit operation signifies that the transaction is successful, and all changes made within the transaction are permanently saved to the database. On the other hand, a rollback operation discards any changes made within the transaction and reverts the database to its state before the transaction begins.

Within the transaction.atomic block, we perform the actual transfer by subtracting the specified amount from the source account and adding it to the destination account. We update the account balances and save the changes to the database. If any exception occurs during the transfer, we catch it and display an error message to the user.

Test the code

In our db, create two users and accounts associated with the user.

Now, let’s use Postman to test the transfer function by transferring $50 from user 1 to user 2.

Verify from the db, we notice the balance remains unchanged.

Now, remove the raise exception line. And run the request again.

Verify from the db, we notice the balance has been changed and committed.

Wait a minute!

Is this code safe now? What if there is another request that concurrently updates the balance?

To ensure the balance is not being updated by another process while the transfer operation is in progress and to maintain consistency, you can use the database’s locking mechanisms. Django’s transaction atomic already provides a basic level of concurrency control by using the transaction.atomic block. However, it doesn't handle concurrent updates outside of the transaction scope.

To implement more advanced locking mechanisms, you can use the select_for_update() method in Django's querysets. Here's an updated version of your transfer function that includes locking to ensure consistency:

from django.db import transaction, models

def transfer(self, request):
    try:
        user_a = request.POST.get("user_a")
        user_b = request.POST.get("user_b")
        amount = request.POST.get("amount")

        with transaction.atomic():
            user_a_obj = Account.objects.select_for_update().get(user=user_a)
            user_a_obj.balance -= int(amount)
            user_a_obj.save()

            user_b_obj = Account.objects.select_for_update().get(user=user_b)
            user_b_obj.balance += int(amount)
            user_b_obj.save()

            return Response(
                {"status": "success", "message": "Your amount is transfered."}
            )

    except Exception as e:
        print(e)
        return Response({"status": "failed", "message": "Something went wrong."})

The select_for_update() method is called on the querysets for user_one_obj and user_two_obj. This method locks the selected rows in the database, preventing other transactions from modifying them until the current transaction is completed.

Bulk insert using Django’s transaction atomic

Here’s an example of how to perform a bulk insert using Django’s transaction atomic feature with the Product model:

from django.db import transaction

# Assume you have a list of products to insert
products_data = [
    {'name': 'Product 1', 'sku': 'SKU1', 'price': 10.99},
    {'name': 'Product 2', 'sku': 'SKU2', 'price': 19.99},
    {'name': 'Product 3', 'sku': 'SKU3', 'price': 14.99},
    # Add more products as needed
]

@transaction.atomic
def create_products(products_data):
    # Create a list to hold the Product objects
    products = []

    try:
        # Iterate over the products_data list
        for data in products_data:
            product = Product(name=data['name'], sku=data['sku'], price=data['price'])
            products.append(product)

        # Use the bulk_create method to insert the products in a single query
        Product.objects.bulk_create(products)

        # Transaction will be committed automatically if no exceptions occur
    except Exception as e:
        # Handle any exceptions that occur during the bulk creation process
        print(f"Error occurred: {e}")
        # Raise an exception to trigger a rollback

Here, we decorate the function with the transaction.atomic decorator to ensure the entire bulk insert operation is treated as an atomic transaction.

After iterating over all the data, we use the bulk_create method of the Product.objects manager to insert all the Product objects in a single query, improving performance compared to individual save() calls.

If any exception occurs during the bulk creation process, we catch it and handle it accordingly. By raising an exception, the transaction will be rolled back, and no changes will persist in the database. If no exceptions occur, the transaction will be committed automatically, and all the products will be inserted into the database.

In this article, we explored Django’s transaction atomic feature and learned how it guarantees the atomicity of database operations. We learned the locking mechanism in Django to ensure data consistency. Additionally, we also learn how to use transaction.atomic decorator to protect bulk inserts operation.




Continue Learning