DEV Community

Cover image for I Eliminated SQLite Race Conditions in a Multi-Threaded Python App πŸš€
Rolan Lobo
Rolan Lobo

Posted on

I Eliminated SQLite Race Conditions in a Multi-Threaded Python App πŸš€

Random crashes. Database corruption. β€œdatabase is locked” errors.

That’s how my app Sortify behaved when multiple threads hit SQLite at the same time.

This post is how I fixed it properly β€” and made the database production-ready.


🧠 The Problem: SQLite + Threads = Trouble

SQLite is lightweight and fast β€” but it has a big footgun:

❌ A single database connection shared across threads is NOT safe

In my app Sortify, multiple components were running concurrently:

  • Auto-sort watcher
  • Manual file operations
  • Scheduler tasks
  • Background processing threads

All of them were touching the same SQLite connection.

Symptoms I Saw

  • Random crashes
  • database is locked errors
  • Inconsistent history data
  • Risk of database corruption
  • App instability during concurrent operations

This line was the silent killer πŸ‘‡

sqlite3.connect(db_path, check_same_thread=False)
Enter fullscreen mode Exit fullscreen mode

It disables safety, but does not make SQLite thread-safe.


πŸ’₯ Why This Happens

SQLite allows multiple connections, but each connection must stay in one thread.

Sharing:

  • ❌ cursors
  • ❌ connections
  • ❌ transactions

across threads causes race conditions.


βœ… The Solution: Thread-Local Database Manager

I implemented a proper thread-safe architecture using:

  • threading.local()
  • Per-thread SQLite connections
  • Automatic retry logic
  • Centralized DB access layer

🧩 Introducing DatabaseManager

A brand-new module:

core/database_manager.py
Enter fullscreen mode Exit fullscreen mode

Key Design Idea

Each thread gets its own SQLite connection

self._local = threading.local()
Enter fullscreen mode Exit fullscreen mode

Connections are:

  • Created on demand
  • Stored per thread
  • Automatically reused inside that thread

πŸ” Enforced Safety

sqlite3.connect(
    db_path,
    timeout=10.0,
    check_same_thread=True  # βœ… SAFE
)
Enter fullscreen mode Exit fullscreen mode

If a thread tries to use another thread’s connection β†’ SQLite blocks it immediately.

That’s what we want.


βš™οΈ Features of DatabaseManager

βœ” Thread-Local Connection Pooling

Each thread has its own isolated connection

βœ” Automatic Retry on Locks

Handles SQLite’s infamous:

OperationalError: database is locked
Enter fullscreen mode Exit fullscreen mode

with retry + backoff logic.

βœ” Transaction Support

execute_transaction(operations)
Enter fullscreen mode Exit fullscreen mode

Ensures atomic writes even under load.

βœ” Clean Shutdown

close_all_connections()
Enter fullscreen mode Exit fullscreen mode

No leaked file handles. No corrupted DBs.


πŸ” Fixing Existing Code

❌ Before: Shared Connection

self.conn = sqlite3.connect(db_path, check_same_thread=False)
self.cursor = self.conn.cursor()
Enter fullscreen mode Exit fullscreen mode

βœ… After: Thread-Safe Manager

from .database_manager import DatabaseManager
self.db_manager = DatabaseManager(self.db_path)
Enter fullscreen mode Exit fullscreen mode

Every database call now goes through one safe gateway.


🧼 Removing Direct Cursor Access

❌ UI Code Touching DB Directly

cursor = self.history_manager.conn.cursor()
cursor.execute("DELETE FROM history")
Enter fullscreen mode Exit fullscreen mode

βœ… Proper Encapsulation

self.history_manager.clear_operations()
self.history_manager.clear_history()
Enter fullscreen mode Exit fullscreen mode

No more hidden race conditions.


πŸ§ͺ Stress Testing the Fix

I didn’t trust this blindly β€” I stress tested it hard.

Test Setup

  • 5 threads
  • 50 DB operations each
  • 250 total concurrent writes

Results

Total operations: 250
Successful: 250
Failed: 0
Database records: 250
Enter fullscreen mode Exit fullscreen mode

πŸŽ‰ Zero failures. Zero locks. Zero corruption.


🧠 Thread-Local Connections Verified

βœ“ Number of unique connections: 3
βœ“ Each thread has its own connection
Enter fullscreen mode Exit fullscreen mode

Exactly as designed.


πŸ“ˆ Impact

Before ❌

  • Random crashes
  • Locked database errors
  • Unsafe concurrent writes
  • App unstable under load

After βœ…

  • Fully thread-safe database access
  • Stable concurrent operations
  • No corruption risk
  • Production-ready SQLite usage

πŸ—‚οΈ Files Changed

File Description
core/database_manager.py New thread-safe DB layer
core/history.py Migrated all queries
ui/main_window.py Removed direct DB access
tests/test_database_threading.py Stress test suite

πŸš€ Lessons Learned

  1. SQLite is thread-friendly, not thread-safe
  2. check_same_thread=False is a trap
  3. One connection per thread is the correct model
  4. Centralizing DB access prevents future bugs
  5. Stress tests reveal bugs unit tests won’t

πŸ”— Source Code

πŸ“¦ GitHub Repository:
πŸ‘‰ https://github.com/Mrtracker-new/Sortify


🏁 Final Thoughts

This wasn’t just a bug fix β€” it was a foundational stability upgrade.

If your Python app:

  • Uses SQLite
  • Has background threads
  • Randomly crashes under load

πŸ‘‰ This pattern will save you.

Happy coding! πŸš€

Top comments (0)