GestionTablets/docs/MIGRATION_TO_POSTGRES.md
ijuanes 1ad0e8bf13 docs: add PostgreSQL migration guide for future scalability
- Add section 9 to TECHNICAL_SPECIFICATIONS.md with:
  - Migration criteria (DB size, connections, users, transactions)
  - Proposed repository pattern architecture
  - Migration steps overview
  - Benefits of PostgreSQL

- Create docs/MIGRATION_TO_POSTGRES.md with comprehensive guide:
  - When to migrate (detailed thresholds)
  - Architecture overview (repository pattern)
  - Step-by-step migration process (10 steps)
  - Complete code examples:
    * Base repository interfaces
    * SQLite implementation
    * PostgreSQL implementation
    * Repository factory
    * Migration script
  - Docker Compose configuration
  - Rollback plan
  - Monitoring and maintenance
  - Performance optimization
  - Troubleshooting guide

This enables future migration when database grows beyond SQLite capabilities.
2026-06-17 17:22:50 +01:00

28 KiB

PostgreSQL Migration Guide

This document describes how to migrate the Tablet Management System from SQLite to PostgreSQL when the database grows beyond SQLite's capabilities.

When to Migrate

Consider migrating to PostgreSQL when you encounter any of these scenarios:

Metric SQLite Limit PostgreSQL Migration Trigger
Database Size ~10GB max Unlimited >1GB
Concurrent Writers 1 Thousands >50 simultaneous
Active Users <100 Millions >200
Transactions/min <100 100K+ >500
Servers Single machine Cluster Multiple servers
High Availability No Yes Required
Backup Strategy Manual Automated Automated needed

Architecture Overview

The migration uses a Repository Pattern to abstract the database layer, allowing both SQLite and PostgreSQL to work seamlessly.

project/
├── backend/
│   ├── config/
│   │   ├── __init__.py
│   │   ├── settings.py          # Database configuration
│   │   └── database.py          # Repository factory
│   ├── repositories/
│   │   ├── __init__.py
│   │   ├── base_repository.py   # Abstract base classes
│   │   ├── sqlite_repo.py       # SQLite implementation
│   │   └── postgres_repo.py     # PostgreSQL implementation
│   └── app.py                   # Main application (unchanged)
├── migrations/                 # Alembic migrations
│   └── versions/
│       └── initial_schema.py
├── scripts/
│   └── migrate_to_postgres.py   # Migration script
└── docker-compose.yml           # Optional Docker setup

Step 1: Install Dependencies

# For development
pip install psycopg2-binary sqlalchemy alembic

# For production (more efficient)
pip install psycopg2 sqlalchemy alembic

Step 2: Set Up PostgreSQL

Option A: Local Installation

# Ubuntu/Debian
sudo apt update
sudo apt install postgresql postgresql-contrib

# Create database and user
sudo -u postgres psql

In PostgreSQL shell:

CREATE DATABASE tablet_management;
CREATE USER tablet_user WITH PASSWORD 'your_secure_password';
GRANT ALL PRIVILEGES ON DATABASE tablet_management TO tablet_user;
ALTER USER tablet_user CREATEDB;
\q
# Start PostgreSQL container
docker run --name tablet-db -e POSTGRES_PASSWORD=your_password -e POSTGRES_USER=tablet_user -e POSTGRES_DB=tablet_management -p 5432:5432 -d postgres:16-alpine

# Or use docker-compose (see docker-compose.yml)
docker-compose up -d postgres

Step 3: Configure Environment

Create a .env file:

# Database configuration
DB_TYPE=postgres                    # or 'sqlite'
DB_URL=postgresql://tablet_user:your_password@localhost:5432/tablet_management

# For SQLite (fallback)
DB_PATH=tablets.db

Or set environment variables:

export DB_TYPE=postgres
export DB_URL=postgresql://tablet_user:your_password@localhost:5432/tablet_management

Step 4: Create Repository Abstraction

Base Repository (Abstract Interface)

# backend/repositories/base_repository.py
from abc import ABC, abstractmethod
from typing import Optional, List
from datetime import datetime


class BaseTabletRepository(ABC):
    @abstractmethod
    def get_by_id(self, tablet_id: int) -> Optional[dict]:
        pass
    
    @abstractmethod
    def get_by_serial(self, serial: str) -> Optional[dict]:
        pass
    
    @abstractmethod
    def get_all(self, status: Optional[str] = None) -> List[dict]:
        pass
    
    @abstractmethod
    def add(self, brand: str, model: str, serial_number: str, notes: Optional[str] = None) -> dict:
        pass
    
    @abstractmethod
    def update_status(self, tablet_id: int, status: str) -> bool:
        pass
    
    @abstractmethod
    def delete(self, tablet_id: int) -> bool:
        pass


class BaseUserRepository(ABC):
    @abstractmethod
    def get_by_id(self, user_id: int) -> Optional[dict]:
        pass
    
    @abstractmethod
    def get_by_identification(self, identification: str) -> Optional[dict]:
        pass
    
    @abstractmethod
    def get_all(self) -> List[dict]:
        pass
    
    @abstractmethod
    def add(self, name: str, email: Optional[str], phone: Optional[str], identification: str) -> dict:
        pass


class BaseLoanRepository(ABC):
    @abstractmethod
    def get_by_id(self, loan_id: int) -> Optional[dict]:
        pass
    
    @abstractmethod
    def get_active_by_tablet(self, tablet_id: int) -> Optional[dict]:
        pass
    
    @abstractmethod
    def get_by_user(self, user_id: int) -> List[dict]:
        pass
    
    @abstractmethod
    def get_all(self, status: Optional[str] = None) -> List[dict]:
        pass
    
    @abstractmethod
    def create(self, tablet_id: int, user_id: int) -> dict:
        pass
    
    @abstractmethod
    def return_loan(self, loan_id: int) -> bool:
        pass


class BaseNonLoanableDeviceRepository(ABC):
    @abstractmethod
    def get_by_id(self, device_id: int) -> Optional[dict]:
        pass
    
    @abstractmethod
    def get_all(self) -> List[dict]:
        pass
    
    @abstractmethod
    def add(self, brand: str, model: str, serial_number: str, device_type: str, 
            location: Optional[str] = None, notes: Optional[str] = None,
            purchase_date: Optional[str] = None, purchase_cost: Optional[float] = None) -> dict:
        pass
    
    @abstractmethod
    def update(self, device_id: int, **kwargs) -> bool:
        pass
    
    @abstractmethod
    def delete(self, device_id: int) -> bool:
        pass

SQLite Implementation

# backend/repositories/sqlite_repo.py
import sqlite3
from typing import Optional, List
from .base_repository import (
    BaseTabletRepository, BaseUserRepository, 
    BaseLoanRepository, BaseNonLoanableDeviceRepository
)


class SQLiteTabletRepository(BaseTabletRepository):
    def __init__(self, db_path: str = 'tablets.db'):
        self.db_path = db_path
        self._init_db()
    
    def _init_db(self):
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS tablets (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                brand TEXT NOT NULL,
                model TEXT NOT NULL,
                serial_number TEXT UNIQUE NOT NULL,
                status TEXT DEFAULT 'available',
                notes TEXT
            )
        ''')
        conn.commit()
        conn.close()
    
    def get_by_id(self, tablet_id: int) -> Optional[dict]:
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        cursor.execute("SELECT * FROM tablets WHERE id = ?", (tablet_id,))
        row = cursor.fetchone()
        conn.close()
        return dict(row) if row else None
    
    def get_by_serial(self, serial: str) -> Optional[dict]:
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        cursor.execute("SELECT * FROM tablets WHERE serial_number = ?", (serial,))
        row = cursor.fetchone()
        conn.close()
        return dict(row) if row else None
    
    def get_all(self, status: Optional[str] = None) -> List[dict]:
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        query = "SELECT * FROM tablets"
        params = ()
        if status:
            query += " WHERE status = ?"
            params = (status,)
        cursor.execute(query, params)
        results = [dict(row) for row in cursor.fetchall()]
        conn.close()
        return results
    
    def add(self, brand: str, model: str, serial_number: str, notes: Optional[str] = None) -> dict:
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        cursor.execute('''
            INSERT INTO tablets (brand, model, serial_number, status, notes)
            VALUES (?, ?, ?, 'available', ?)
        ''', (brand, model, serial_number, notes))
        conn.commit()
        tablet_id = cursor.lastrowid
        cursor.execute("SELECT * FROM tablets WHERE id = ?", (tablet_id,))
        row = cursor.fetchone()
        conn.close()
        return dict(row)
    
    def update_status(self, tablet_id: int, status: str) -> bool:
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        cursor.execute("UPDATE tablets SET status = ? WHERE id = ?", (status, tablet_id))
        conn.commit()
        changed = cursor.rowcount > 0
        conn.close()
        return changed
    
    def delete(self, tablet_id: int) -> bool:
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        cursor.execute("DELETE FROM tablets WHERE id = ?", (tablet_id,))
        conn.commit()
        deleted = cursor.rowcount > 0
        conn.close()
        return deleted


# Similar implementations for SQLiteUserRepository, SQLiteLoanRepository, etc.

PostgreSQL Implementation

# backend/repositories/postgres_repo.py
import psycopg2
from psycopg2 import sql
from psycopg2.extras import DictCursor
from typing import Optional, List
from .base_repository import (
    BaseTabletRepository, BaseUserRepository,
    BaseLoanRepository, BaseNonLoanableDeviceRepository
)


class PostgreSQLTabletRepository(BaseTabletRepository):
    def __init__(self, connection_string: str):
        self.connection_string = connection_string
        self._init_db()
    
    def _get_connection(self):
        return psycopg2.connect(self.connection_string)
    
    def _init_db(self):
        conn = self._get_connection()
        cursor = conn.cursor()
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS tablets (
                id SERIAL PRIMARY KEY,
                brand VARCHAR(100) NOT NULL,
                model VARCHAR(100) NOT NULL,
                serial_number VARCHAR(50) UNIQUE NOT NULL,
                status VARCHAR(20) DEFAULT 'available',
                notes TEXT,
                created_at TIMESTAMP DEFAULT NOW(),
                updated_at TIMESTAMP DEFAULT NOW()
            )
        ''')
        cursor.execute('''
            CREATE INDEX IF NOT EXISTS idx_tablets_serial 
            ON tablets(serial_number)
        ''')
        cursor.execute('''
            CREATE INDEX IF NOT EXISTS idx_tablets_status 
            ON tablets(status)
        ''')
        conn.commit()
        cursor.close()
        conn.close()
    
    def get_by_id(self, tablet_id: int) -> Optional[dict]:
        conn = self._get_connection()
        cursor = conn.cursor(cursor_factory=DictCursor)
        cursor.execute("SELECT * FROM tablets WHERE id = %s", (tablet_id,))
        row = cursor.fetchone()
        conn.close()
        return dict(row) if row else None
    
    def get_by_serial(self, serial: str) -> Optional[dict]:
        conn = self._get_connection()
        cursor = conn.cursor(cursor_factory=DictCursor)
        cursor.execute("SELECT * FROM tablets WHERE serial_number = %s", (serial,))
        row = cursor.fetchone()
        conn.close()
        return dict(row) if row else None
    
    def get_all(self, status: Optional[str] = None) -> List[dict]:
        conn = self._get_connection()
        cursor = conn.cursor(cursor_factory=DictCursor)
        query = "SELECT * FROM tablets"
        params = ()
        if status:
            query += " WHERE status = %s"
            params = (status,)
        cursor.execute(query, params)
        results = [dict(row) for row in cursor.fetchall()]
        conn.close()
        return results
    
    def add(self, brand: str, model: str, serial_number: str, notes: Optional[str] = None) -> dict:
        conn = self._get_connection()
        cursor = conn.cursor(cursor_factory=DictCursor)
        cursor.execute('''
            INSERT INTO tablets (brand, model, serial_number, status, notes)
            VALUES (%s, %s, %s, 'available', %s)
            RETURNING *
        ''', (brand, model, serial_number, notes))
        row = cursor.fetchone()
        conn.commit()
        conn.close()
        return dict(row)
    
    def update_status(self, tablet_id: int, status: str) -> bool:
        conn = self._get_connection()
        cursor = conn.cursor()
        cursor.execute(
            "UPDATE tablets SET status = %s, updated_at = NOW() WHERE id = %s",
            (status, tablet_id)
        )
        conn.commit()
        changed = cursor.rowcount > 0
        conn.close()
        return changed
    
    def delete(self, tablet_id: int) -> bool:
        conn = self._get_connection()
        cursor = conn.cursor()
        cursor.execute("DELETE FROM tablets WHERE id = %s", (tablet_id,))
        conn.commit()
        deleted = cursor.rowcount > 0
        conn.close()
        return deleted


# Similar implementations for PostgreSQLUserRepository, PostgreSQLLoanRepository, etc.

Step 5: Create Repository Factory

# backend/config/database.py
import os
from backend.repositories.sqlite_repo import (
    SQLiteTabletRepository, SQLiteUserRepository,
    SQLiteLoanRepository, SQLiteNonLoanableDeviceRepository
)
from backend.repositories.postgres_repo import (
    PostgreSQLTabletRepository, PostgreSQLUserRepository,
    PostgreSQLLoanRepository, PostgreSQLNonLoanableDeviceRepository
)
from backend.repositories.base_repository import (
    BaseTabletRepository, BaseUserRepository,
    BaseLoanRepository, BaseNonLoanableDeviceRepository
)


class DatabaseConfig:
    def __init__(self):
        self.db_type = os.getenv('DB_TYPE', 'sqlite')
        self.db_url = os.getenv('DB_URL', '')
        self.db_path = os.getenv('DB_PATH', 'tablets.db')
    
    @property
    def is_postgres(self) -> bool:
        return self.db_type == 'postgres'


def get_tablet_repository() -> BaseTabletRepository:
    config = DatabaseConfig()
    if config.is_postgres:
        return PostgreSQLTabletRepository(config.db_url)
    else:
        return SQLiteTabletRepository(config.db_path)


def get_user_repository() -> BaseUserRepository:
    config = DatabaseConfig()
    if config.is_postgres:
        return PostgreSQLUserRepository(config.db_url)
    else:
        return SQLiteUserRepository(config.db_path)


def get_loan_repository() -> BaseLoanRepository:
    config = DatabaseConfig()
    if config.is_postgres:
        return PostgreSQLLoanRepository(config.db_url)
    else:
        return SQLiteLoanRepository(config.db_path)


def get_non_loanable_device_repository() -> BaseNonLoanableDeviceRepository:
    config = DatabaseConfig()
    if config.is_postgres:
        return PostgreSQLNonLoanableDeviceRepository(config.db_url)
    else:
        return SQLiteNonLoanableDeviceRepository(config.db_path)

Step 6: Update Application to Use Repositories

Modify your application to use the repository pattern:

# In your app.py or service layer
from backend.config.database import (
    get_tablet_repository, get_user_repository,
    get_loan_repository, get_non_loanable_device_repository
)

# Instead of direct SQLite calls:
tablet_repo = get_tablet_repository()
user_repo = get_user_repository()
loan_repo = get_loan_repository()

# Example: Loan a tablet
def loan_tablet(tablet_id: int, user_id: int):
    # Get repositories
    tablet_repo = get_tablet_repository()
    user_repo = get_user_repository()
    loan_repo = get_loan_repository()
    
    # Validate
    tablet = tablet_repo.get_by_id(tablet_id)
    if not tablet:
        raise ValueError("Tablet not found")
    
    if tablet['status'] != 'available':
        raise ValueError("Tablet not available")
    
    user = user_repo.get_by_id(user_id)
    if not user:
        raise ValueError("User not found")
    
    # Check for active loan
    active_loan = loan_repo.get_active_by_tablet(tablet_id)
    if active_loan:
        raise ValueError("Tablet already loaned")
    
    # Create loan
    loan = loan_repo.create(tablet_id, user_id)
    
    # Update tablet status
    tablet_repo.update_status(tablet_id, 'loaned')
    
    return loan

Step 7: Create Migration Script

# scripts/migrate_to_postgres.py
#!/usr/bin/env python3
"""
Migration script from SQLite to PostgreSQL
"""
import sqlite3
import psycopg2
from psycopg2.extras import DictCursor
import argparse
from tqdm import tqdm
import os


def create_postgres_tables(conn):
    """Create all tables in PostgreSQL"""
    cursor = conn.cursor()
    
    # Tablets
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS tablets (
            id SERIAL PRIMARY KEY,
            brand VARCHAR(100) NOT NULL,
            model VARCHAR(100) NOT NULL,
            serial_number VARCHAR(50) UNIQUE NOT NULL,
            status VARCHAR(20) DEFAULT 'available',
            notes TEXT,
            created_at TIMESTAMP DEFAULT NOW(),
            updated_at TIMESTAMP DEFAULT NOW()
        )
    ''')
    
    # Users
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS users (
            id SERIAL PRIMARY KEY,
            name VARCHAR(100) NOT NULL,
            email VARCHAR(255),
            phone VARCHAR(20),
            identification VARCHAR(50) UNIQUE NOT NULL,
            created_at TIMESTAMP DEFAULT NOW(),
            updated_at TIMESTAMP DEFAULT NOW()
        )
    ''')
    
    # Loans
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS loans (
            id SERIAL PRIMARY KEY,
            tablet_id INTEGER NOT NULL REFERENCES tablets(id) ON DELETE RESTRICT,
            user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
            loan_date TIMESTAMP NOT NULL DEFAULT NOW(),
            return_date TIMESTAMP,
            status VARCHAR(20) DEFAULT 'active',
            created_at TIMESTAMP DEFAULT NOW()
        )
    ''')
    
    # Non-loanable devices
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS non_loanable_devices (
            id SERIAL PRIMARY KEY,
            brand VARCHAR(100) NOT NULL,
            model VARCHAR(100) NOT NULL,
            serial_number VARCHAR(50) UNIQUE NOT NULL,
            device_type VARCHAR(50) NOT NULL,
            location VARCHAR(100),
            status VARCHAR(20) DEFAULT 'available',
            notes TEXT,
            purchase_date DATE,
            purchase_cost DECIMAL(10,2),
            created_at TIMESTAMP DEFAULT NOW(),
            updated_at TIMESTAMP DEFAULT NOW()
        )
    ''')
    
    # Indexes for performance
    cursor.execute('CREATE INDEX IF NOT EXISTS idx_tablets_serial ON tablets(serial_number)')
    cursor.execute('CREATE INDEX IF NOT EXISTS idx_tablets_status ON tablets(status)')
    cursor.execute('CREATE INDEX IF NOT EXISTS idx_tablets_brand ON tablets(brand)')
    cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_identification ON users(identification)')
    cursor.execute('CREATE INDEX IF NOT EXISTS idx_loans_tablet ON loans(tablet_id)')
    cursor.execute('CREATE INDEX IF NOT EXISTS idx_loans_user ON loans(user_id)')
    cursor.execute('CREATE INDEX IF NOT EXISTS idx_loans_status ON loans(status)')
    
    conn.commit()


def migrate_table(conn_sqlite, conn_pg, table_name: str, pg_create_table: str):
    """Generic migration for a table"""
    cursor_sqlite = conn_sqlite.cursor()
    cursor_pg = conn_pg.cursor()
    
    # Get all data from SQLite
    cursor_sqlite.execute(f"SELECT * FROM {table_name}")
    rows = cursor_sqlite.fetchall()
    
    if not rows:
        print(f"No data to migrate for {table_name}")
        return
    
    # Get column names
    column_names = [desc[0] for desc in cursor_sqlite.description]
    
    # Prepare INSERT statement
    columns = ', '.join(column_names)
    placeholders = ', '.join(['%s'] * len(column_names))
    insert_sql = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
    
    # Migrate data
    for row in tqdm(rows, desc=f"Migrating {table_name}"):
        cursor_pg.execute(insert_sql, row)
    
    conn_pg.commit()
    print(f"✓ Migrated {len(rows)} rows from {table_name}")


def migrate_all(sqlite_path: str, pg_url: str):
    """Migrate all data from SQLite to PostgreSQL"""
    print("Starting migration from SQLite to PostgreSQL...")
    
    # Connect to SQLite
    conn_sqlite = sqlite3.connect(sqlite_path)
    
    # Connect to PostgreSQL
    conn_pg = psycopg2.connect(pg_url)
    
    try:
        # Create tables
        print("Creating PostgreSQL tables...")
        create_postgres_tables(conn_pg)
        
        # Migrate each table
        migrate_table(conn_sqlite, conn_pg, 'tablets', '')
        migrate_table(conn_sqlite, conn_pg, 'users', '')
        migrate_table(conn_sqlite, conn_pg, 'loans', '')
        migrate_table(conn_sqlite, conn_pg, 'non_loanable_devices', '')
        
        print("\n✓ Migration completed successfully!")
        print(f"  SQLite: {sqlite_path}")
        print(f"  PostgreSQL: {pg_url}")
        
    except Exception as e:
        conn_pg.rollback()
        print(f"\n✗ Migration failed: {e}")
        raise
    finally:
        conn_sqlite.close()
        conn_pg.close()


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Migrate from SQLite to PostgreSQL')
    parser.add_argument('--sqlite', default='tablets.db', help='SQLite database path')
    parser.add_argument('--postgres', required=True, help='PostgreSQL connection URL')
    args = parser.parse_args()
    
    migrate_all(args.sqlite, args.postgres)

Step 8: Run Migration

# Test the migration first (dry run)
python scripts/migrate_to_postgres.py --sqlite tablets.db --postgres postgresql://tablet_user:password@localhost:5432/tablet_management_test

# Verify data in test database
psql -U tablet_user -d tablet_management_test -c "SELECT COUNT(*) FROM tablets;"

# When ready, migrate to production
python scripts/migrate_to_postgres.py --sqlite tablets.db --postgres postgresql://tablet_user:password@localhost:5432/tablet_management

Step 9: Switch to PostgreSQL

# Update environment variables
export DB_TYPE=postgres
export DB_URL=postgresql://tablet_user:password@localhost:5432/tablet_management

# Restart application
python app.py

Step 10: Verify and Monitor

# Check application logs for errors
# Monitor database connections
psql -U tablet_user -d tablet_management -c "SELECT COUNT(*) FROM tablets;"

# Check active connections
psql -U postgres -c "SELECT * FROM pg_stat_activity WHERE datname = 'tablet_management';"

Rollback Plan

If something goes wrong:

  1. Immediate rollback:
    # Switch back to SQLite
    

export DB_TYPE=sqlite export DB_PATH=tablets.db python app.py


2. **Data verification:**
```bash
# Compare counts
sqlite3 tablets.db "SELECT COUNT(*) FROM tablets;"
psql -U tablet_user -d tablet_management -c "SELECT COUNT(*) FROM tablets;"
  1. Backup PostgreSQL data:
    pg_dump -U tablet_user -d tablet_management > postgres_backup_$(date +%Y%m%d).sql
    

Docker Compose (Optional)

For easy deployment with Docker:

# docker-compose.yml
version: '3.8'

services:
  postgres:
    image: postgres:16-alpine
    container_name: tablet_db
    environment:
      POSTGRES_DB: tablet_management
      POSTGRES_USER: tablet_user
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tablet_user -d tablet_management"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  app:
    build: .
    container_name: tablet_app
    environment:
      DB_TYPE: postgres
      DB_URL: postgresql://tablet_user:${POSTGRES_PASSWORD:-changeme}@postgres:5432/tablet_management
    ports:
      - "5000:5000"
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

volumes:
  postgres_data:

Start with Docker:

docker-compose up -d

Benefits of PostgreSQL

Performance

  • Concurrency: Multiple writers simultaneously (no lock contention)
  • Indexing: Advanced index types (B-tree, Hash, GiST, GIN, BRIN)
  • Query Optimization: Advanced query planner
  • Connection Pooling: Built-in support

Scalability

  • Vertical: Handles large datasets efficiently
  • Horizontal: Read replicas, partitioning, sharding
  • Connections: Supports thousands of concurrent connections

Reliability

  • ACID Compliance: Full transaction support
  • Point-in-Time Recovery: Restore to any moment
  • Replication: Master-slave, synchronous, asynchronous
  • Backups: pg_dump, pg_basebackup, continuous archiving

Security

  • Authentication: Multiple methods (password, MD5, SCRAM, LDAP, Kerberos)
  • Authorization: Role-based access control (RBAC)
  • Row-Level Security: Policies for fine-grained access
  • Encryption: SSL, at-rest encryption

Features

  • JSON Support: Native JSON/JSONB data type
  • Full-Text Search: Advanced text search capabilities
  • Arrays: Store arrays of values
  • Custom Types: Create your own data types
  • Triggers: Automatic actions on events
  • Stored Procedures: Server-side functions

Monitoring PostgreSQL

Basic Queries

-- Active connections
SELECT * FROM pg_stat_activity WHERE datname = 'tablet_management';

-- Table sizes
SELECT table_name, pg_size_pretty(pg_total_relation_size(table_name))
FROM information_schema.tables WHERE table_schema = 'public';

-- Index usage
SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes;

-- Slow queries (requires pg_stat_statements extension)
SELECT query, total_time, calls, mean_time
FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 10;

Enable pg_stat_statements

-- In PostgreSQL
CREATE EXTENSION pg_stat_statements;

-- Then in postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all

Maintenance Tasks

Regular Maintenance

# Vacuum (reclaim space, update statistics)
vacuumdb -U tablet_user -d tablet_management --analyze

# Reindex (rebuild indexes)
reindexdb -U tablet_user -d tablet_management

Backup Strategy

# Daily backup
pg_dump -U tablet_user -d tablet_management > /backups/tablet_management_$(date +%Y%m%d).sql

# Compressed backup
pg_dump -U tablet_user -d tablet_management | gzip > /backups/tablet_management_$(date +%Y%m%d).sql.gz

# Continuous archiving (WAL)
# In postgresql.conf:
wal_level = replica
archive_mode = on
archive_command = 'test ! -f /backups/wal/%f && cp %p /backups/wal/%f'

Performance Optimization

Configuration Tuning

# postgresql.conf recommendations
shared_buffers = 4GB                   # 25% of total RAM
work_mem = 16MB                        # For complex sorts
maintenance_work_mem = 512MB          # For VACUUM, index creation
effective_cache_size = 12GB           # 75% of total RAM
random_page_cost = 1.1                # SSD: 1.1, HDD: 4.0
max_worker_processes = 8              # Number of CPU cores
max_parallel_workers_per_gather = 4   # Parallel query workers
max_connections = 200                 # Expected max connections

Index Optimization

-- Add indexes for common queries
CREATE INDEX idx_loans_user_status ON loans(user_id, status);
CREATE INDEX idx_loans_date_range ON loans(loan_date, return_date);

-- Partial index for active loans
CREATE INDEX idx_loans_active ON loans(tablet_id) WHERE status = 'active';

-- Composite index for user loans
CREATE INDEX idx_loans_user_tablet ON loans(user_id, tablet_id);

Troubleshooting

Common Issues

Connection refused:

# Check if PostgreSQL is running
sudo systemctl status postgresql

# Check port
netstat -tuln | grep 5432

Authentication failed:

# Verify user and password
psql -U tablet_user -d tablet_management -h localhost

# Check pg_hba.conf
sudo nano /etc/postgresql/16/main/pg_hba.conf

Database does not exist:

# Create database
createdb -U postgres tablet_management

Permission denied:

-- Grant permissions
GRANT ALL PRIVILEGES ON DATABASE tablet_management TO tablet_user;
GRANT ALL ON SCHEMA public TO tablet_user;

Logs

# PostgreSQL logs
sudo tail -f /var/log/postgresql/postgresql-16-main.log

# Application logs
journalctl -u tablet_management -f

Conclusion

Migrating from SQLite to PostgreSQL provides:

  • Better performance at scale
  • True concurrency
  • Enhanced reliability
  • Advanced features
  • Production-ready infrastructure

The repository pattern ensures a smooth transition with minimal code changes, and the migration script automates the data transfer process.