Batch Processing Meets Real-Time: Optimizing Bulk Absenteeism in FastAPI
I ran into significant performance and data consistency issues when integrating a nightly bulk absenteeism job with our real-time attendance system. This post details how I refactored the process for efficiency and reliability.

Batch Processing Meets Real-Time: Optimizing Bulk Absenteeism in FastAPI
Building a real-time attendance management system is a balancing act. On one side, you have the immediate, low-latency demands of capturing and verifying student attendance via WebSockets. On the other, you have the administrative reality of processing data in bulk, often on a schedule. I learned this balance the hard way when integrating a critical nightly job: automated bulk absenteeism processing.
Our system, built with FastAPI, WebSockets, and SQLAlchemy, handled hundreds of concurrent connections, syncing GPS-verified attendance in under 50 milliseconds. That part was solid. But the bulk absenteeism job was a different beast. It was designed to automatically mark students who hadn't checked in by a certain time as 'absent' for the day. This automation was supposed to cut administrative workload by 80%, a huge win on paper.
The Problem: A Nightly Choke Point
Initially, the bulk absenteeism logic seemed simple enough. Every night, a scheduled task would run. It needed to:
- Fetch all active students.
- For each student, check if they had any attendance records for the current day.
- If no records were found, mark them as absent.
This process was crucial. It ensured that, come morning, administrators had a clean, accurate attendance roll without manually sifting through thousands of records. The goal was admirable, but my initial implementation fell short. Significantly short.
When we first pushed it to production, the job ran. But not well. It would start, and within minutes, the entire real-time attendance system would start to sputter. WebSocket connections would experience noticeable lag, some even dropping. Database CPU usage would shoot up, and the job itself would take hours to complete for a student body of a few thousand. Sometimes, it would even time out or crash, leaving us with a partially updated, inconsistent attendance record. This was a classic case of a batch operation trampling over a live, high-concurrency system.
Initial Implementation & Its Flaws
My first stab at the bulk processing script was straightforward, almost naive. It looked something like this, conceptually:
# Simplified pseudo-code for initial approach
from datetime import date
from sqlalchemy.orm import Session
from app.database import get_db # Your DB session getter
from app.models import Student, Attendance
def mark_absent_students_naive():
db: Session = next(get_db())
today = date.today()
# Fetch all active students
students = db.query(Student).filter(Student.is_active == True).all()
for student in students:
# Check if student has attendance for today (N+1 query issue here)
has_attendance = db.query(Attendance).filter(
Attendance.student_id == student.id,
Attendance.date == today
).first()
if not has_attendance:
# Mark student as absent (another N+1 query for each absent student)
existing_absent_record = db.query(Attendance).filter(
Attendance.student_id == student.id,
Attendance.date == today,
Attendance.status == 'absent'
).first()
if not existing_absent_record:
new_absent_record = Attendance(
student_id=student.id,
date=today,
status='absent',
# ... other fields
)
db.add(new_absent_record)
db.commit() # Committing inside the loop is a major problem
else:
# Ensure status is 'absent' if it somehow got changed
if existing_absent_record.status != 'absent':
existing_absent_record.status = 'absent'
db.commit()
db.close()
print("Naive bulk absent marking complete.")
This code, while logically correct for a single student, was a disaster at scale. Here’s why:
- N+1 Query Problem: For every
studentin thestudentslist, I was executing at least one, potentially two, additional database queries (has_attendanceandexisting_absent_record). If we had 5,000 students, this meant 5,000 to 10,000 extra queries just to check status. This alone hammered the database connection pool and CPU. - Commit Inside Loop: The
db.commit()call inside theforloop was a critical error. Eachdb.add()ordb.update()followed by adb.commit()initiates and finalizes a separate database transaction. For thousands of students, this meant thousands of tiny, independent transactions. Each transaction has overhead – logging, lock acquisition, durability guarantees. This overhead accumulates rapidly. - Row-Level Locking & Contention: With thousands of individual
INSERTorUPDATEstatements, the database was constantly acquiring and releasing row-level locks. While row-level locks are fine for concurrent writes on different rows, they still add overhead. More critically, the sheer volume of these operations meant increased contention for shared resources (like the transaction log, index pages, or even just the database connection itself). This contention directly impacted the real-time attendance marking process, which was also trying to write to theAttendancetable. - Lack of Atomicity for the Batch: If the script crashed after processing, say, 2,000 students, the first 2,000 would be marked absent, but the remaining 3,000 would not. This left the system in an inconsistent state, requiring manual intervention or a full re-run, which would then re-process the already-marked students, adding more unnecessary load.
The symptoms were clear: the database CPU spiked, read/write latency increased, and the real-time WebSocket connections started dropping or lagging because the database couldn't keep up with both the batch job and the constant stream of live attendance updates.
Root Cause Analysis: The Database is Smarter Than My Python Loop
The fundamental issue was trying to solve a set-based problem with an iterative, procedural approach in Python. Databases, especially relational ones like PostgreSQL (which we were using via SQLAlchemy), are highly optimized for operating on sets of data. My Python script was effectively pulling data out, processing it row-by-row, and pushing it back in, when the database could have done the entire operation internally, far more efficiently.
Let's break down the core problems:
- Inefficient Data Transfer: Fetching every student record, then checking attendance for each, meant a constant back-and-forth between the application server and the database server. Network latency, even within a VPS, adds up when multiplied by thousands of queries.
- Excessive Transaction Overhead: As mentioned, thousands of commits. Each commit forces the database to write changes to disk (or at least acknowledge them as durable). This is a heavy operation.
- Locking Granularity: While PostgreSQL's MVCC (Multi-Version Concurrency Control) is excellent, frequent
UPDATEandINSERTstatements, especially on the same table, still introduce contention points. Even if row-level locks prevent deadlocks on different rows, the system still has to manage these locks, and the sheer volume of operations can cause general resource contention. - Lack of Bulk
UPSERT: The initial logic involvedSELECTto check, thenINSERTorUPDATE. This is a common pattern, but it's inefficient. Modern SQL databases offerUPSERT(e.g.,INSERT ... ON CONFLICT DO UPDATEin PostgreSQL) or other mechanisms to handle this in a single atomic statement.
The root cause was a mismatch between the problem's nature (bulk, set-based data manipulation) and the chosen solution's approach (row-by-row application logic).
The Solution: Batch SQL and Atomic Transactions
The fix involved a fundamental shift in how I thought about the problem: offloading as much logic as possible to the database, and performing operations in large, atomic batches.
Here's the refactored approach:
- Identify Students for Absenteeism in a Single Query: Instead of iterating through students and querying attendance for each, I needed a single SQL query that could identify all students who were active and had no attendance records for the current day. This could be done with a
LEFT JOINand aWHEREclause checking forNULLvalues in the joined table. - Perform Bulk
UPSERT: Once identified, these students needed to be marked absent. Instead ofSELECTthenINSERT/UPDATE, a singleINSERT ... ON CONFLICT DO UPDATEstatement (or a similarUPDATE ... FROMconstruct) would be ideal to either create a new 'absent' record or update an existing one if its status was somehow wrong. - Single Atomic Transaction: Wrap the entire operation (identification and marking) in one database transaction. This ensures atomicity: either all students are correctly processed, or none are.
Let's look at the code changes. First, I defined a new function for the optimized bulk operation:
from datetime import date
from sqlalchemy import select, func, and_
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Student, Attendance
from typing import List
from sqlalchemy.dialects.postgresql import insert # For PostgreSQL specific ON CONFLICT
async def mark_absent_students_optimized(db: Session):
today = date.today()
# Step 1: Identify students who are active and have no attendance for today
# This uses a LEFT JOIN to find students without a matching attendance record.
students_to_mark_absent_query = (
select(Student.id)
.outerjoin(
select(Attendance.student_id)
.filter(Attendance.date == today)
.distinct()
.subquery('attended_students'),
Student.id == func.attended_students.c.student_id
)
.where(
Student.is_active == True,
func.attended_students.c.student_id == None # Students who did NOT attend
)
)
# Execute the query to get a list of student IDs
result = await db.execute(students_to_mark_absent_query)
student_ids_to_mark_absent: List[int] = result.scalars().all()
if not student_ids_to_mark_absent:
print("No students to mark absent for today.")
return
print(f"Identified {len(student_ids_to_mark_absent)} students to mark absent.")
# Step 2: Perform a bulk UPSERT operation
absent_records_data = [
{"student_id": student_id, "date": today, "status": "absent"}
for student_id in student_ids_to_mark_absent
]
if absent_records_data:
# Construct the INSERT statement with ON CONFLICT DO NOTHING
# This will insert new records, and if a record with the same (student_id, date) primary key/unique constraint exists,
# it will do nothing, ensuring existing 'present' or 'absent' records are not overwritten or re-inserted.
# Assuming (student_id, date) is a unique constraint on the Attendance table.
stmt = insert(Attendance).values(absent_records_data)
stmt = stmt.on_conflict_do_nothing(
index_elements=['student_id', 'date'] # Assuming unique constraint on these columns
)
await db.execute(stmt)
# Commit the entire batch operation in one go
await db.commit()
print(f"Successfully processed {len(student_ids_to_mark_absent)} students for absenteeism.")
Let's break down the mark_absent_students_optimized function:
-
students_to_mark_absent_query: This is the core of the optimization. Instead of a Python loop, I'm usingSQLAlchemy's query builder to construct a single, efficient SQL query. It performs aLEFT JOINfrom theStudenttable to a subquery that finds all students who do have attendance records for today. By filteringWHERE func.attended_students.c.student_id == None, I'm asking the database to give me only those active students for whom no attendance record exists for today. This is a single database round trip to identify all target students. -
result.scalars().all(): This fetches all identifiedstudent_ids into a Python list. This list contains only the IDs of students who genuinely need to be marked absent, not every student in the system. -
insert(Attendance).values(absent_records_data).on_conflict_do_nothing(...): This is where theUPSERTmagic happens for new records. I create a list of dictionaries, each representing a potential newAttendancerecord withstatus='absent'. Then, usingsqlalchemy.dialects.postgresql.insertwithon_conflict_do_nothing, I tell PostgreSQL:- Try to
INSERTthese records. - If a record with the same
student_idanddatealready exists (due to a unique constraint on these columns), just do nothing. This is crucial. It means if a student already has a 'present' record, or even an 'absent' record from a previous run, it won't be overwritten or cause an error. We only care about inserting records for students who don't have any attendance record for the day.
- Try to
-
await db.execute(stmt)andawait db.commit(): The entire operation, from identifying students to performing the bulkINSERT, is executed within a single asynchronous transaction. This drastically reduces transaction overhead and ensures atomicity. If any part of thedb.execute(stmt)fails, the entire transaction is rolled back, preventing inconsistent states.
This approach transformed the nightly job. What used to take hours and bring the system to its knees now completes in seconds for the same number of students, with negligible impact on the real-time system. Database CPU usage remains stable, and WebSocket latency is unaffected during the job run.
Why This Works: Database Power and Transactional Integrity
- Set-Based Operations: The database processes the
LEFT JOINandWHEREclause internally, often using highly optimized algorithms and indexes. It avoids the thousands of individual trips between the application and the database that the N+1 approach caused. - Single Transaction: By wrapping the entire bulk operation in one transaction, the database can optimize its internal operations. It doesn't have to constantly commit changes to disk, acquire/release locks, or update transaction logs for each row. This significantly reduces I/O and CPU overhead.
- Reduced Locking Contention: While the bulk
INSERTstill involves writing, it's a single, larger operation. The database can manage the necessary locks more efficiently for a single large transaction than for thousands of tiny ones.ON CONFLICT DO NOTHINGis particularly good as it avoids contention by simply skipping rows that would cause a conflict, rather than attempting an update that might contend for the same row. - Idempotency: The
on_conflict_do_nothingclause makes the operation idempotent. Running the job multiple times on the same day won't create duplicate records or alter correctly marked attendance. - Async/Await with SQLAlchemy: Using
await db.execute()ensures that even though the database is doing heavy lifting, our FastAPI application's event loop isn't blocked. This allows the server to continue handling real-time WebSocket connections and other API requests without interruption while the database processes the bulk task.
Lessons Learned
This experience reinforced several critical lessons in building robust, scalable backend systems:
- Leverage Database Capabilities: Databases are incredibly powerful. For set-based data manipulation, always try to push the logic down to the database layer rather than pulling data into your application code to process iteratively. SQL is often far more efficient for these tasks than Python loops.
- Batch Operations Are Essential: Any time you're dealing with a large number of records, think in terms of batches. Whether it's
UPSERT,UPDATE ... FROM, orbulk_insert_mappings, batching operations dramatically reduces overhead and improves performance. - Transaction Management is Paramount: Understand database transactions. Committing inside a loop is almost always a red flag for bulk operations. A single, well-defined transaction ensures atomicity, consistency, and better resource management.
- Consider the Impact on Concurrent Systems: When introducing batch jobs, always evaluate their potential impact on your live, real-time systems. Performance profiling and load testing are crucial. Separate concerns where possible, perhaps using dedicated worker processes or queues for heavy batch tasks, but the core optimization should still be efficient database interactions.
- Profile and Optimize: Don't assume. Measure the performance of your batch jobs. Use database monitoring tools to identify slow queries, lock contention, and high resource utilization. This data is invaluable for pinpointing bottlenecks and guiding optimization efforts.
Moving from a naive, row-by-row approach to a set-based, transactional batch update strategy was a significant win. It not only solved a critical performance bottleneck but also ensured data integrity and allowed our real-time attendance system to operate smoothly, even during intensive background processing. It's a prime example of how a deeper understanding of database internals and thoughtful application of SQL can make a massive difference in production.
Frequently Asked Questions
What challenges arise when integrating batch jobs with real-time systems?
Integrating batch jobs with real-time systems often leads to challenges like database contention, N+1 query problems, potential data inconsistencies, and performance degradation if not carefully managed. Batch operations can lock resources that real-time processes need, causing slowdowns or deadlocks.
How can N+1 query problems be avoided in batch processing?
N+1 query problems in batch processing can be avoided by leveraging the database's capabilities for set-based operations. Instead of iterating through records and issuing individual queries, use techniques like `UPDATE ... FROM`, `INSERT ... ON CONFLICT`, or temporary tables to perform updates or inserts in a single, efficient SQL statement.
Why is proper transaction management important for bulk updates?
Proper transaction management is crucial for bulk updates to ensure atomicity and consistency. Wrapping the entire batch operation in a single transaction guarantees that either all updates succeed, or none do. This prevents partial, inconsistent states if the process fails midway and helps manage database locks more effectively.
What is the role of `UPDATE ... FROM` in database optimization?
`UPDATE ... FROM` (or similar vendor-specific syntax like `UPDATE JOIN`) allows updating rows in a target table based on data from another table or a subquery. This enables efficient, set-based updates, drastically reducing the number of individual `UPDATE` statements and improving performance for bulk operations compared to row-by-row processing.
Written by Ansh Gautam
Full-stack engineer building production systems with FastAPI, React, and AI/LLM integrations. Currently looking for backend engineering & AI integration roles.