Skip to content

Python SQLite3 튜토리얼: Python에서 SQLite 데이터베이스 완전 정복 가이드

Updated on

Python 애플리케이션에서 구조화된 데이터를 저장해야 할 때가 있습니다. 사용자 설정, 센서 측정값, 로그 엔트리, 또는 할 일 목록일 수도 있죠. CSV 파일을 쓰려다 보니 데이터가 커지면서 조회가 고통스러워집니다. PostgreSQL이나 MySQL을 고려해보지만, 로컬 도구나 프로토타입을 위해 DB 서버를 세팅하는 건 과한 느낌입니다. 결국 필요한 건 “오버헤드 없이” 구조화된 데이터를 안정적으로 저장하고, 조회하고, 업데이트할 수 있는 방법입니다.

바로 이 문제를 SQLite가 해결합니다. SQLite는 하나의 파일에 모든 것을 저장하는 자체 포함(self-contained) 서버리스 데이터베이스 엔진입니다. Python은 표준 라이브러리에 sqlite3 모듈을 기본으로 포함하므로 설치할 것도 없습니다. 서버 프로세스를 띄우거나 설정 파일을 관리하지 않아도 완전한 SQL 지원, ACID 트랜잭션, 그리고 최대 281TB까지의 데이터셋 처리 능력을 얻을 수 있습니다.

이 가이드는 Python에서 SQLite를 효과적으로 사용하는 데 필요한 모든 것을 다룹니다. 첫 데이터베이스 생성부터 고급 쿼리, pandas 연동, 성능 튜닝, 그리고 실제 프로젝트 예제까지 이어집니다.

📚

SQLite란 무엇이며, 왜 사용해야 할까?

SQLite는 임베디드(embedded) 관계형 데이터베이스 엔진입니다. PostgreSQL이나 MySQL과 달리 별도의 서버 프로세스로 실행되지 않습니다. 전체 데이터베이스는 디스크(또는 메모리)상의 단일 파일에 존재합니다. Python 표준 라이브러리의 sqlite3 모듈은 SQLite에 대한 DB-API 2.0 호환 인터페이스를 제공합니다.

SQLite의 핵심 특징:

  • 제로 설정(Zero configuration): 서버 설정 없음, 사용자/권한 관리 없음
  • 서버리스(Serverless): 데이터베이스 엔진이 애플리케이션 프로세스 내부에서 실행됨
  • 단일 파일(Single file): 전체 데이터베이스(테이블, 인덱스, 데이터)가 하나의 .db 파일
  • 크로스 플랫폼(Cross-platform): 변환 없이 어떤 OS에서도 동일 파일 사용 가능
  • ACID 준수(ACID compliant): commit/rollback을 포함한 완전한 트랜잭션 지원
  • 경량(Lightweight): 라이브러리 크기가 약 750KB로, 대부분의 이미지보다 작음

언제 SQLite를 사용하면 좋을까?

Use CaseSQLitePostgreSQL/MySQL
데스크톱/모바일 앱최선의 선택과함
프로토타이핑최선의 선택세팅이 느림
임베디드 디바이스 / IoT최선의 선택현실적으로 어려움
유닛 테스트최선의 선택테스트 서버 필요
데이터 분석 스크립트좋은 선택불필요한 오버헤드
일 방문 < 100K인 웹앱잘 동작더 확장 가능
고동시성 웹앱적합하지 않음최선의 선택
여러 write-heavy 클라이언트적합하지 않음최선의 선택

SQLite는 동시에 여러 쓰기 연결이 필요한 경우만 아니면 대부분의 애플리케이션에서 충분합니다. Android와 iOS는 로컬 DB의 기본으로 SQLite를 사용하고, Firefox/Chrome/macOS도 내부적으로 사용합니다. 전 세계에서 가장 많이 배포된 데이터베이스 엔진입니다.

데이터베이스에 연결하기

sqlite3.connect() 함수는 데이터베이스 파일에 대한 연결을 생성합니다. 파일이 없으면 SQLite가 자동으로 생성합니다.

import sqlite3
 
# Connect to a database file (creates it if it doesn't exist)
conn = sqlite3.connect("myapp.db")
 
# Create a cursor to execute SQL statements
cursor = conn.cursor()
 
# Always close the connection when done
conn.close()

인메모리(In-Memory) 데이터베이스

특수 문자열 :memory:를 사용하면 RAM에만 존재하는 데이터베이스를 만들 수 있습니다. 테스트나 임시 데이터 처리에 이상적입니다.

import sqlite3
 
conn = sqlite3.connect(":memory:")
cursor = conn.cursor()
 
cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)")
cursor.execute("INSERT INTO test VALUES (1, 'hello')")
cursor.execute("SELECT * FROM test")
print(cursor.fetchone())  # (1, 'hello')
 
conn.close()  # Database is gone after this

컨텍스트 매니저 사용하기

권장 패턴은 컨텍스트 매니저(with 구문)를 사용해 오류가 나더라도 연결이 올바르게 정리되도록 하는 것입니다. SQLite 연결은 with 블록 내부에서 성공 시 자동 commit, 예외 발생 시 자동 rollback을 수행합니다.

import sqlite3
 
# Connection as context manager handles commit/rollback
with sqlite3.connect("myapp.db") as conn:
    cursor = conn.cursor()
    cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
    cursor.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
    # Auto-commits if no exception
    # Auto-rolls back if an exception occurs
 
# Note: the connection is NOT closed by 'with' -- it just commits/rolls back
# For full cleanup, close explicitly or use a wrapper:
conn.close()

commit/rollback과 close를 모두 처리하는 깔끔한 헬퍼 함수:

import sqlite3
from contextlib import contextmanager
 
@contextmanager
def get_db(db_path="myapp.db"):
    """Context manager that commits on success, rolls back on error, and always closes."""
    conn = sqlite3.connect(db_path)
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()
 
# Usage
with get_db() as conn:
    conn.execute("INSERT INTO users (name) VALUES (?)", ("Bob",))

테이블 생성하기

CREATE TABLE로 스키마를 정의합니다. SQLite는 5가지 저장 클래스(storage class)를 갖는 단순화된 타입 시스템을 지원합니다.

SQLite 데이터 타입

Storage ClassDescriptionPython Equivalent
NULLNull valueNone
INTEGERSigned integer (1, 2, 3, 4, 6, or 8 bytes)int
REALFloating point (8-byte IEEE float)float
TEXTUTF-8 or UTF-16 stringstr
BLOBBinary data, stored exactly as inputbytes

SQLite는 동적 타이핑을 사용합니다. 선언된 타입과 무관하게 어떤 컬럼에도 어떤 타입이든 저장할 수 있습니다. 선언 타입은 제약이 아니라 힌트에 가깝습니다. 이는 PostgreSQL, MySQL과 다른 점입니다.

제약 조건을 포함한 테이블 생성

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
cursor.execute("""
    CREATE TABLE IF NOT EXISTS employees (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT UNIQUE NOT NULL,
        department TEXT DEFAULT 'General',
        salary REAL CHECK(salary > 0),
        hire_date TEXT NOT NULL,
        is_active INTEGER DEFAULT 1
    )
""")
 
conn.commit()
conn.close()

핵심 제약 조건:

  • PRIMARY KEY: 각 행(row)의 고유 식별자. INTEGER PRIMARY KEY는 내부 rowid의 별칭입니다.
  • AUTOINCREMENT: rowid가 항상 증가하도록 보장(삭제된 ID 재사용 방지).
  • NOT NULL: NULL 불가.
  • UNIQUE: 중복 값 불가.
  • DEFAULT: INSERT 시 값 미지정이면 기본값 사용.
  • CHECK: 조건에 따른 데이터 유효성 검사.

여러 개의 연관 테이블 생성

import sqlite3
 
conn = sqlite3.connect("school.db")
cursor = conn.cursor()
 
# Enable foreign key enforcement (off by default in SQLite)
cursor.execute("PRAGMA foreign_keys = ON")
 
cursor.execute("""
    CREATE TABLE IF NOT EXISTS students (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT UNIQUE
    )
""")
 
cursor.execute("""
    CREATE TABLE IF NOT EXISTS courses (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        credits INTEGER DEFAULT 3
    )
""")
 
cursor.execute("""
    CREATE TABLE IF NOT EXISTS enrollments (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        student_id INTEGER NOT NULL,
        course_id INTEGER NOT NULL,
        grade TEXT,
        enrolled_date TEXT DEFAULT CURRENT_DATE,
        FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
        FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE,
        UNIQUE(student_id, course_id)
    )
""")
 
conn.commit()
conn.close()

CRUD 작업

CRUD는 Create, Read, Update, Delete의 약자로, 영속 저장소에서 가장 기본이 되는 4가지 작업을 의미합니다.

INSERT: 데이터 추가

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
# Insert a single row
cursor.execute(
    "INSERT INTO employees (name, email, department, salary, hire_date) VALUES (?, ?, ?, ?, ?)",
    ("Alice Johnson", "alice@company.com", "Engineering", 95000, "2024-01-15")
)
 
# Insert multiple rows with executemany
employees = [
    ("Bob Smith", "bob@company.com", "Marketing", 72000, "2024-03-01"),
    ("Carol White", "carol@company.com", "Engineering", 98000, "2023-11-20"),
    ("David Brown", "david@company.com", "Sales", 68000, "2024-06-10"),
    ("Eve Davis", "eve@company.com", "Engineering", 105000, "2023-08-05"),
]
 
cursor.executemany(
    "INSERT INTO employees (name, email, department, salary, hire_date) VALUES (?, ?, ?, ?, ?)",
    employees
)
 
# Get the last inserted row ID
print(f"Last inserted ID: {cursor.lastrowid}")
 
# Get the number of rows affected
print(f"Rows inserted: {cursor.rowcount}")
 
conn.commit()
conn.close()

INSERT OR REPLACE (Upsert)

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
# Insert or update if email already exists
cursor.execute("""
    INSERT INTO employees (name, email, department, salary, hire_date)
    VALUES (?, ?, ?, ?, ?)
    ON CONFLICT(email) DO UPDATE SET
        name = excluded.name,
        salary = excluded.salary
""", ("Alice Johnson", "alice@company.com", "Engineering", 100000, "2024-01-15"))
 
conn.commit()
conn.close()

SELECT: 데이터 조회

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
# Select all rows
cursor.execute("SELECT * FROM employees")
all_rows = cursor.fetchall()
for row in all_rows:
    print(row)  # Returns tuples by default
 
# Select specific columns with a condition
cursor.execute(
    "SELECT name, salary FROM employees WHERE department = ?",
    ("Engineering",)
)
engineers = cursor.fetchall()
for name, salary in engineers:
    print(f"{name}: ${salary:,.0f}")
 
# Select with ordering and limit
cursor.execute("""
    SELECT name, salary FROM employees
    ORDER BY salary DESC
    LIMIT 3
""")
top_earners = cursor.fetchall()
print("Top 3 earners:", top_earners)
 
conn.close()

결과를 딕셔너리로 받기

기본적으로 fetchall()은 튜플을 반환합니다. 컬럼명을 키로 하는 딕셔너리를 얻고 싶다면 sqlite3.Row를 사용하세요.

import sqlite3
 
conn = sqlite3.connect("myapp.db")
conn.row_factory = sqlite3.Row  # Set row factory
 
cursor = conn.cursor()
cursor.execute("SELECT * FROM employees WHERE department = ?", ("Engineering",))
 
for row in cursor.fetchall():
    print(dict(row))  # Convert Row to dict
    print(row["name"], row["salary"])  # Access by column name
 
conn.close()

UPDATE: 데이터 수정

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
# Update a single field
cursor.execute(
    "UPDATE employees SET salary = ? WHERE email = ?",
    (110000, "alice@company.com")
)
print(f"Rows updated: {cursor.rowcount}")
 
# Update with a calculation
cursor.execute("""
    UPDATE employees
    SET salary = salary * 1.10
    WHERE department = 'Engineering' AND salary < 100000
""")
print(f"Engineers with raises: {cursor.rowcount}")
 
# Conditional update
cursor.execute("""
    UPDATE employees
    SET is_active = 0
    WHERE hire_date < '2023-01-01'
""")
 
conn.commit()
conn.close()

DELETE: 데이터 삭제

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
# Delete specific rows
cursor.execute("DELETE FROM employees WHERE is_active = 0")
print(f"Rows deleted: {cursor.rowcount}")
 
# Delete with a subquery
cursor.execute("""
    DELETE FROM enrollments
    WHERE student_id NOT IN (SELECT id FROM students)
""")
 
# Delete all rows (truncate equivalent)
cursor.execute("DELETE FROM employees")
 
conn.commit()
conn.close()

매개변수화 쿼리와 SQL Injection 방지

문자열 포맷팅이나 f-string으로 SQL을 조합하지 마세요. SQL injection 공격을 막기 위해 항상 매개변수화 쿼리를 사용해야 합니다.

문제: SQL Injection

import sqlite3
 
# DANGEROUS: Never do this
user_input = "'; DROP TABLE employees; --"
query = f"SELECT * FROM employees WHERE name = '{user_input}'"
# This would execute: SELECT * FROM employees WHERE name = ''; DROP TABLE employees; --'

해결: 매개변수화 쿼리

SQLite3는 두 가지 placeholder 스타일을 지원합니다.

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
# Style 1: Question mark placeholders (positional)
cursor.execute(
    "SELECT * FROM employees WHERE department = ? AND salary > ?",
    ("Engineering", 90000)
)
 
# Style 2: Named placeholders
cursor.execute(
    "SELECT * FROM employees WHERE department = :dept AND salary > :min_salary",
    {"dept": "Engineering", "min_salary": 90000}
)
 
# Named placeholders are clearer with many parameters
cursor.execute("""
    INSERT INTO employees (name, email, department, salary, hire_date)
    VALUES (:name, :email, :dept, :salary, :date)
""", {
    "name": "Frank Green",
    "email": "frank@company.com",
    "dept": "Engineering",
    "salary": 92000,
    "date": "2025-09-01"
})
 
conn.commit()
conn.close()

매개변수화 쿼리는 이스케이프와 quoting을 자동으로 처리합니다. DB 엔진은 매개변수 값을 SQL 코드가 아니라 “데이터”로만 취급하므로, 입력 내용이 무엇이든 SQL injection이 원천적으로 차단됩니다.

동적 WHERE 절

필터가 선택 사항인 검색처럼, 쿼리를 동적으로 구성해야 할 때가 있습니다. 아래는 안전한 패턴입니다.

import sqlite3
 
def search_employees(department=None, min_salary=None, is_active=None):
    conn = sqlite3.connect("myapp.db")
    cursor = conn.cursor()
 
    conditions = []
    params = []
 
    if department is not None:
        conditions.append("department = ?")
        params.append(department)
 
    if min_salary is not None:
        conditions.append("salary >= ?")
        params.append(min_salary)
 
    if is_active is not None:
        conditions.append("is_active = ?")
        params.append(is_active)
 
    query = "SELECT * FROM employees"
    if conditions:
        query += " WHERE " + " AND ".join(conditions)
 
    cursor.execute(query, params)
    results = cursor.fetchall()
    conn.close()
    return results
 
# Usage
engineers = search_employees(department="Engineering", min_salary=90000)
active_staff = search_employees(is_active=1)
everyone = search_employees()  # No filters

트랜잭션 다루기

트랜잭션은 여러 SQL 작업을 하나의 원자적(atomic) 단위로 묶습니다. 모든 작업이 성공하면 commit, 하나라도 실패하면 rollback으로 아무 일도 없었던 상태로 되돌립니다.

수동 트랜잭션 제어

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
try:
    # Transfer money between accounts
    cursor.execute("UPDATE accounts SET balance = balance - 500 WHERE id = 1")
    cursor.execute("UPDATE accounts SET balance = balance + 500 WHERE id = 2")
 
    # Verify no negative balance
    cursor.execute("SELECT balance FROM accounts WHERE id = 1")
    balance = cursor.fetchone()[0]
    if balance < 0:
        raise ValueError("Insufficient funds")
 
    conn.commit()  # All good, save changes
    print("Transfer complete")
 
except Exception as e:
    conn.rollback()  # Something went wrong, undo everything
    print(f"Transfer failed: {e}")
 
finally:
    conn.close()

Autocommit과 Isolation Levels

기본적으로 sqlite3는 “deferred transaction” 모드로 동작합니다. 필요에 따라 변경할 수 있습니다.

import sqlite3
 
# Default behavior: transactions are deferred
conn = sqlite3.connect("myapp.db")
 
# Autocommit mode (each statement is its own transaction)
conn = sqlite3.connect("myapp.db", isolation_level=None)
 
# Immediate locking (prevents concurrent write conflicts)
conn = sqlite3.connect("myapp.db")
conn.execute("BEGIN IMMEDIATE")
# ... your operations ...
conn.commit()

Savepoint로 부분 롤백하기

Savepoint를 사용하면 트랜잭션 전체를 되돌리지 않고 일부만 롤백할 수 있습니다.

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
try:
    cursor.execute("INSERT INTO orders (customer_id, total) VALUES (1, 100)")
 
    # Start a savepoint
    cursor.execute("SAVEPOINT order_items")
 
    try:
        cursor.execute("INSERT INTO order_items (order_id, product, qty) VALUES (1, 'Widget', 5)")
        cursor.execute("INSERT INTO order_items (order_id, product, qty) VALUES (1, 'Gadget', -1)")
        # This might fail due to CHECK constraint
        cursor.execute("RELEASE SAVEPOINT order_items")
    except sqlite3.IntegrityError:
        cursor.execute("ROLLBACK TO SAVEPOINT order_items")
        print("Some items failed, but order was still created")
 
    conn.commit()
except Exception:
    conn.rollback()
finally:
    conn.close()

데이터 가져오기: fetchone, fetchall, fetchmany

cursor 객체는 쿼리 결과를 가져오기 위한 세 가지 메서드를 제공합니다.

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
cursor.execute("SELECT * FROM employees ORDER BY salary DESC")
 
# fetchone: Get the next single row (or None if no more rows)
top_earner = cursor.fetchone()
print(f"Top earner: {top_earner}")
 
# fetchmany: Get the next N rows
next_three = cursor.fetchmany(3)
print(f"Next 3: {next_three}")
 
# fetchall: Get all remaining rows
remaining = cursor.fetchall()
print(f"Remaining: {len(remaining)} rows")
 
conn.close()

결과를 효율적으로 순회하기

큰 결과 집합은 전부 메모리에 올리지 말고 cursor를 직접 순회하세요.

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
cursor.execute("SELECT name, salary FROM employees")
 
# Memory-efficient: processes one row at a time
for name, salary in cursor:
    print(f"{name}: ${salary:,.0f}")
 
conn.close()

fetchmany로 배치 처리하기

수백만 행을 처리할 때는 fetchmany()를 반복 호출하여 메모리 사용량을 제어하는 것이 좋습니다.

import sqlite3
 
conn = sqlite3.connect("large_data.db")
cursor = conn.cursor()
 
cursor.execute("SELECT * FROM sensor_readings")
 
batch_size = 1000
while True:
    rows = cursor.fetchmany(batch_size)
    if not rows:
        break
    # Process batch
    for row in rows:
        pass  # your processing logic
 
conn.close()

고급 쿼리

JOIN

import sqlite3
 
conn = sqlite3.connect("school.db")
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
 
# INNER JOIN: Students with their enrolled courses
cursor.execute("""
    SELECT s.name AS student, c.title AS course, e.grade
    FROM enrollments e
    INNER JOIN students s ON e.student_id = s.id
    INNER JOIN courses c ON e.course_id = c.id
    ORDER BY s.name, c.title
""")
 
for row in cursor.fetchall():
    print(f"{row['student']} - {row['course']}: {row['grade'] or 'In Progress'}")
 
# LEFT JOIN: All students, even those not enrolled
cursor.execute("""
    SELECT s.name, COUNT(e.id) AS course_count
    FROM students s
    LEFT JOIN enrollments e ON s.id = e.student_id
    GROUP BY s.id
    ORDER BY course_count DESC
""")
 
for row in cursor.fetchall():
    print(f"{row['name']}: {row['course_count']} courses")
 
conn.close()

집계(Aggregation)

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
# Department statistics
cursor.execute("""
    SELECT
        department,
        COUNT(*) AS headcount,
        ROUND(AVG(salary), 2) AS avg_salary,
        MIN(salary) AS min_salary,
        MAX(salary) AS max_salary,
        SUM(salary) AS total_payroll
    FROM employees
    WHERE is_active = 1
    GROUP BY department
    HAVING COUNT(*) >= 2
    ORDER BY avg_salary DESC
""")
 
print(f"{'Department':<15} {'Count':<8} {'Avg Salary':<12} {'Min':<10} {'Max':<10}")
print("-" * 55)
for row in cursor.fetchall():
    dept, count, avg_sal, min_sal, max_sal, total = row
    print(f"{dept:<15} {count:<8} ${avg_sal:<11,.0f} ${min_sal:<9,.0f} ${max_sal:<9,.0f}")
 
conn.close()

서브쿼리(Subqueries)

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
# Employees earning above their department average
cursor.execute("""
    SELECT name, department, salary
    FROM employees e
    WHERE salary > (
        SELECT AVG(salary) FROM employees
        WHERE department = e.department
    )
    ORDER BY department, salary DESC
""")
 
for name, dept, salary in cursor.fetchall():
    print(f"{name} ({dept}): ${salary:,.0f}")
 
# Most recent hire in each department
cursor.execute("""
    SELECT name, department, hire_date
    FROM employees
    WHERE (department, hire_date) IN (
        SELECT department, MAX(hire_date)
        FROM employees
        GROUP BY department
    )
""")
 
for row in cursor.fetchall():
    print(row)
 
conn.close()

윈도우 함수(Window Functions, SQLite 3.25+)

SQLite는 누적 합계, 랭킹 등 분석용 쿼리를 위한 윈도우 함수를 지원합니다.

import sqlite3
 
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
 
# Rank employees by salary within each department
cursor.execute("""
    SELECT
        name,
        department,
        salary,
        RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank,
        RANK() OVER (ORDER BY salary DESC) AS overall_rank
    FROM employees
    WHERE is_active = 1
""")
 
for name, dept, salary, dept_rank, overall_rank in cursor.fetchall():
    print(f"#{overall_rank} overall, #{dept_rank} in {dept}: {name} (${salary:,.0f})")
 
# Running total of payroll by hire date
cursor.execute("""
    SELECT
        name,
        hire_date,
        salary,
        SUM(salary) OVER (ORDER BY hire_date) AS running_total
    FROM employees
    ORDER BY hire_date
""")
 
for name, date, salary, running in cursor.fetchall():
    print(f"{date} | {name}: ${salary:,.0f} | Running total: ${running:,.0f}")
 
conn.close()

pandas와 SQLite

pandas는 read_sql()to_sql()을 통해 SQLite를 기본 지원합니다. DataFrame과 SQLite 데이터베이스 간 데이터 이동이 매우 간단해집니다.

SQL 결과를 DataFrame으로 읽기

import sqlite3
import pandas as pd
 
conn = sqlite3.connect("myapp.db")
 
# Read a SQL query into a DataFrame
df = pd.read_sql("SELECT * FROM employees WHERE is_active = 1", conn)
print(df.head())
print(df.describe())
 
# Parameterized query with pandas
df = pd.read_sql(
    "SELECT * FROM employees WHERE department = ? AND salary > ?",
    conn,
    params=("Engineering", 90000)
)
 
# Read an entire table
df = pd.read_sql("SELECT * FROM employees", conn, index_col="id")
 
conn.close()

DataFrame을 SQLite에 쓰기

import sqlite3
import pandas as pd
 
# Create a sample DataFrame
df = pd.DataFrame({
    "product": ["Laptop", "Phone", "Tablet", "Monitor", "Keyboard"],
    "price": [999.99, 699.99, 449.99, 349.99, 79.99],
    "stock": [50, 200, 75, 30, 500],
    "category": ["Electronics", "Electronics", "Electronics", "Peripherals", "Peripherals"]
})
 
conn = sqlite3.connect("store.db")
 
# Write DataFrame to a new table
df.to_sql("products", conn, if_exists="replace", index=False)
 
# Append to an existing table
new_products = pd.DataFrame({
    "product": ["Mouse", "Webcam"],
    "price": [49.99, 89.99],
    "stock": [300, 100],
    "category": ["Peripherals", "Peripherals"]
})
new_products.to_sql("products", conn, if_exists="append", index=False)
 
# Verify
result = pd.read_sql("SELECT * FROM products", conn)
print(result)
 
conn.close()

왕복 워크플로우: CSV → SQLite → 분석

import sqlite3
import pandas as pd
 
# Step 1: Load CSV into SQLite
df = pd.read_csv("sales_data.csv")
conn = sqlite3.connect("analytics.db")
df.to_sql("sales", conn, if_exists="replace", index=False)
 
# Step 2: Run SQL analytics
summary = pd.read_sql("""
    SELECT
        region,
        strftime('%Y-%m', sale_date) AS month,
        COUNT(*) AS transactions,
        SUM(amount) AS revenue,
        AVG(amount) AS avg_transaction
    FROM sales
    GROUP BY region, month
    ORDER BY month, revenue DESC
""", conn)
 
print(summary)
 
# Step 3: Export results
summary.to_csv("monthly_summary.csv", index=False)
 
conn.close()

쿼리 결과를 DataFrame으로 얻었다면, PyGWalker (opens in a new tab)를 사용해 바로 시각화할 수 있습니다. PyGWalker는 어떤 pandas DataFrame이든 Tableau와 비슷한 인터랙티브 시각화 UI로 변환해주며, 필드를 드래그 앤 드롭해서 플로팅 코드를 작성하지 않고도 차트를 만들 수 있습니다.

import pygwalker as pyg
 
# Turn your SQL query results into an interactive visualization
walker = pyg.walk(summary)

더 매끄러운 워크플로우를 원한다면, RunCell (opens in a new tab)은 AI 기반 Jupyter 환경을 제공하여 SQL 쿼리 작성, AI 보조 디버깅, 결과 시각화를 하나의 노트북에서 즉시 처리할 수 있습니다.

오류 처리

sqlite3 모듈은 여러 예외 타입을 정의합니다. 구체적인 예외를 잡을수록 더 나은 에러 메시지와 복구 전략을 만들 수 있습니다.

import sqlite3
 
def safe_insert(db_path, name, email, salary):
    """Insert an employee with proper error handling."""
    try:
        conn = sqlite3.connect(db_path)
        cursor = conn.cursor()
        cursor.execute(
            "INSERT INTO employees (name, email, salary, hire_date) VALUES (?, ?, ?, date('now'))",
            (name, email, salary)
        )
        conn.commit()
        return cursor.lastrowid
 
    except sqlite3.IntegrityError as e:
        # Unique constraint violated (duplicate email, NOT NULL violated, etc.)
        print(f"Data integrity error: {e}")
        return None
 
    except sqlite3.OperationalError as e:
        # Table doesn't exist, SQL syntax error, database locked, etc.
        print(f"Operational error: {e}")
        return None
 
    except sqlite3.DatabaseError as e:
        # Corrupted database, disk full, etc.
        print(f"Database error: {e}")
        return None
 
    except sqlite3.ProgrammingError as e:
        # Using a closed cursor, wrong number of parameters, etc.
        print(f"Programming error: {e}")
        return None
 
    finally:
        if conn:
            conn.close()

SQLite3 예외 계층 구조

ExceptionParentCommon Causes
sqlite3.WarningException치명적이지 않은 문제
sqlite3.ErrorExceptionsqlite3 오류의 베이스 클래스
sqlite3.InterfaceErrorErrorDB-API 인터페이스 오용
sqlite3.DatabaseErrorError데이터베이스 관련 오류
sqlite3.DataErrorDatabaseError데이터 처리 문제
sqlite3.OperationalErrorDatabaseErrorDB 잠김, 테이블 없음, SQL 오류
sqlite3.IntegrityErrorDatabaseErrorUNIQUE, NOT NULL, FOREIGN KEY 위반
sqlite3.InternalErrorDatabaseErrorsqlite3 모듈 내부 오류
sqlite3.ProgrammingErrorDatabaseErrorAPI 사용 실수
sqlite3.NotSupportedErrorDatabaseError지원되지 않는 기능

성능 팁

WAL 모드 활성화

Write-Ahead Logging(WAL) 모드는 쓰는 동안에도 읽기를 동시에 허용하고, 쓰기 성능을 개선합니다.

import sqlite3
 
conn = sqlite3.connect("myapp.db")
conn.execute("PRAGMA journal_mode=WAL")
# Returns 'wal' if successful

WAL 모드는 (예: 웹 서버) 리더와 (예: 백그라운드 작업) 라이터가 같은 데이터베이스를 함께 접근할 때 특히 유용합니다.

대량 INSERT에는 executemany 사용

executemany는 Python↔SQLite 왕복을 줄여, 루프에서 execute를 반복 호출하는 것보다 훨씬 빠릅니다.

import sqlite3
import time
 
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE numbers (value INTEGER)")
 
data = [(i,) for i in range(100_000)]
 
# Slow: individual inserts
start = time.perf_counter()
for d in data:
    conn.execute("INSERT INTO numbers VALUES (?)", d)
conn.commit()
slow_time = time.perf_counter() - start
 
conn.execute("DELETE FROM numbers")
 
# Fast: executemany
start = time.perf_counter()
conn.executemany("INSERT INTO numbers VALUES (?)", data)
conn.commit()
fast_time = time.perf_counter() - start
 
print(f"Individual inserts: {slow_time:.2f}s")
print(f"executemany:        {fast_time:.2f}s")
print(f"Speedup:            {slow_time / fast_time:.1f}x")
 
conn.close()

자주 조회하는 컬럼에는 인덱스 생성

인덱스는 SELECT를 크게 빠르게 만들지만, 쓰기 성능이 약간 느려지고 디스크 사용량이 증가합니다.

import sqlite3
 
conn = sqlite3.connect("myapp.db")
 
# Single column index
conn.execute("CREATE INDEX IF NOT EXISTS idx_employees_department ON employees(department)")
 
# Composite index (for queries that filter on both columns)
conn.execute("CREATE INDEX IF NOT EXISTS idx_employees_dept_salary ON employees(department, salary)")
 
# Unique index (also enforces uniqueness)
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_employees_email ON employees(email)")
 
# Check existing indexes
cursor = conn.execute("SELECT name, sql FROM sqlite_master WHERE type='index'")
for name, sql in cursor.fetchall():
    print(f"{name}: {sql}")
 
conn.close()

EXPLAIN QUERY PLAN으로 성능 점검

import sqlite3
 
conn = sqlite3.connect("myapp.db")
 
# Check how SQLite executes a query
result = conn.execute("""
    EXPLAIN QUERY PLAN
    SELECT * FROM employees WHERE department = 'Engineering' AND salary > 90000
""").fetchall()
 
for row in result:
    print(row)
# Output shows whether an index is used or a full table scan occurs
 
conn.close()

성능을 위한 추가 PRAGMA

import sqlite3
 
conn = sqlite3.connect("myapp.db")
 
# Increase cache size (default is 2000 pages = ~8MB)
conn.execute("PRAGMA cache_size = -64000")  # 64MB (negative = KB)
 
# Reduce disk syncs for speed (less durable but much faster)
conn.execute("PRAGMA synchronous = NORMAL")  # Options: OFF, NORMAL, FULL (default)
 
# Store temp tables in memory
conn.execute("PRAGMA temp_store = MEMORY")
 
# Check database integrity
result = conn.execute("PRAGMA integrity_check").fetchone()
print(f"Integrity: {result[0]}")  # Should print 'ok'
 
conn.close()

실전 예제: Task Manager CLI

아래는 이 가이드에서 다룬 개념을 모두 보여주는 완전한 작업 관리자 애플리케이션입니다. SQLite를 영속 저장소로 사용하며, 우선순위, 태그, 마감일을 지원합니다.

import sqlite3
import sys
from datetime import datetime, date
from contextlib import contextmanager
 
DB_PATH = "tasks.db"
 
@contextmanager
def get_db():
    """Get a database connection with Row factory and foreign keys enabled."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys = ON")
    conn.execute("PRAGMA journal_mode = WAL")
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()
 
def init_db():
    """Create tables if they don't exist."""
    with get_db() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS tasks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                description TEXT DEFAULT '',
                priority INTEGER DEFAULT 2 CHECK(priority BETWEEN 1 AND 4),
                status TEXT DEFAULT 'todo' CHECK(status IN ('todo', 'in_progress', 'done')),
                due_date TEXT,
                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
                completed_at TEXT
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS tags (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT UNIQUE NOT NULL
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS task_tags (
                task_id INTEGER NOT NULL,
                tag_id INTEGER NOT NULL,
                PRIMARY KEY (task_id, tag_id),
                FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
                FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
            )
        """)
        conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
        conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)")
        conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date)")
 
def add_task(title, description="", priority=2, due_date=None, tags=None):
    """Add a new task with optional tags."""
    with get_db() as conn:
        cursor = conn.execute(
            "INSERT INTO tasks (title, description, priority, due_date) VALUES (?, ?, ?, ?)",
            (title, description, priority, due_date)
        )
        task_id = cursor.lastrowid
 
        if tags:
            for tag_name in tags:
                conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag_name,))
                tag_id = conn.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)).fetchone()["id"]
                conn.execute("INSERT INTO task_tags (task_id, tag_id) VALUES (?, ?)", (task_id, tag_id))
 
        print(f"Task #{task_id} created: {title}")
        return task_id
 
def list_tasks(status=None, priority=None, tag=None):
    """List tasks with optional filters."""
    with get_db() as conn:
        conditions = []
        params = []
 
        if status:
            conditions.append("t.status = ?")
            params.append(status)
        if priority:
            conditions.append("t.priority = ?")
            params.append(priority)
        if tag:
            conditions.append("t.id IN (SELECT task_id FROM task_tags tt JOIN tags tg ON tt.tag_id = tg.id WHERE tg.name = ?)")
            params.append(tag)
 
        query = """
            SELECT t.id, t.title, t.priority, t.status, t.due_date,
                   GROUP_CONCAT(tg.name, ', ') AS tags
            FROM tasks t
            LEFT JOIN task_tags tt ON t.id = tt.task_id
            LEFT JOIN tags tg ON tt.tag_id = tg.id
        """
        if conditions:
            query += " WHERE " + " AND ".join(conditions)
        query += " GROUP BY t.id ORDER BY t.priority ASC, t.due_date ASC"
 
        rows = conn.execute(query, params).fetchall()
 
        priority_labels = {1: "URGENT", 2: "HIGH", 3: "MEDIUM", 4: "LOW"}
        print(f"\n{'ID':<5} {'Priority':<10} {'Status':<13} {'Due':<12} {'Title':<30} {'Tags'}")
        print("-" * 85)
        for row in rows:
            due = row["due_date"] or "—"
            tags_str = row["tags"] or "—"
            p_label = priority_labels.get(row["priority"], "?")
            print(f"{row['id']:<5} {p_label:<10} {row['status']:<13} {due:<12} {row['title']:<30} {tags_str}")
 
        print(f"\nTotal: {len(rows)} tasks")
 
def complete_task(task_id):
    """Mark a task as done."""
    with get_db() as conn:
        result = conn.execute(
            "UPDATE tasks SET status = 'done', completed_at = ? WHERE id = ?",
            (datetime.now().isoformat(), task_id)
        )
        if result.rowcount:
            print(f"Task #{task_id} marked as done.")
        else:
            print(f"Task #{task_id} not found.")
 
def stats():
    """Show task statistics."""
    with get_db() as conn:
        row = conn.execute("""
            SELECT
                COUNT(*) AS total,
                SUM(CASE WHEN status = 'todo' THEN 1 ELSE 0 END) AS todo,
                SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) AS in_progress,
                SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done,
                SUM(CASE WHEN due_date < date('now') AND status != 'done' THEN 1 ELSE 0 END) AS overdue
            FROM tasks
        """).fetchone()
 
        print(f"\nTask Statistics:")
        print(f"  Total:       {row['total']}")
        print(f"  To Do:       {row['todo']}")
        print(f"  In Progress: {row['in_progress']}")
        print(f"  Done:        {row['done']}")
        print(f"  Overdue:     {row['overdue']}")
 
# Initialize and demo
if __name__ == "__main__":
    init_db()
 
    # Add sample tasks
    add_task("Design database schema", priority=1, due_date="2026-02-20", tags=["backend", "database"])
    add_task("Write API endpoints", priority=2, due_date="2026-02-25", tags=["backend", "api"])
    add_task("Create unit tests", priority=3, tags=["testing"])
    add_task("Update documentation", priority=4, due_date="2026-03-01", tags=["docs"])
 
    # List and stats
    list_tasks()
    stats()
    complete_task(1)
    list_tasks(status="done")

SQLite vs PostgreSQL vs MySQL vs MongoDB

FeatureSQLitePostgreSQLMySQLMongoDB
TypeEmbeddedClient-serverClient-serverDocument store
SetupZero configInstall + configureInstall + configureInstall + configure
StorageSingle fileServer directoryServer directoryServer directory
Max DB size281 TBUnlimited256 TBUnlimited
Concurrent writesLimited (file lock)Excellent (MVCC)Good (row locks)Good (document locks)
SQL supportMost of SQL92Full SQL + extensionsFull SQLMQL (not SQL)
Data types5 storage classes40+ types30+ typesBSON types
Full-text searchFTS5 extensionBuilt-in tsvectorBuilt-in FULLTEXTBuilt-in text index
JSON supportJSON1 extensionNative JSONBNative JSONNative (it is JSON)
ReplicationNot built-inStreaming + logicalPrimary-replicaReplica sets
Best forEmbedded, local, prototypesWeb apps, analyticsWeb apps, CMSFlexible schema, documents
Python librarysqlite3 (built-in)psycopg2mysql-connectorpymongo
Hosting requiredNoYesYesYes
Cost to startFree, zero overheadFree, but need serverFree, but need serverFree, but need server

결정 기준은 명확합니다. 애플리케이션이 데이터베이스에 쓰기 작업을 수행하는 유일한 프로세스라면 SQLite를 사용하세요. 여러 사용자/프로세스의 동시 쓰기, 복제, 네트워크 접근이 필요하다면 PostgreSQL이나 MySQL을 사용하세요. 데이터가 문서 중심이며 관계형 스키마에 잘 맞지 않는다면 MongoDB가 적합할 수 있습니다.

자주 묻는 질문(FAQ)

SQLite는 프로덕션 환경에 적합한가요?

네. SQLite는 Android, iOS, 주요 웹 브라우저, Airbus 비행 소프트웨어, 그리고 수많은 데스크톱 애플리케이션에서 프로덕션으로 사용됩니다. 최대 281TB와 수백만 행을 처리할 수 있습니다. 다만 동시에 쓰기 요청이 매우 많은 고동시성 웹 애플리케이션에는 적합하지 않지만, 그 외 대부분의 시나리오에서는 잘 동작합니다.

Python이 사용 중인 SQLite 버전은 어떻게 확인하나요?

SQLite 라이브러리 버전은 sqlite3.sqlite_version, Python 모듈 버전은 sqlite3.version으로 확인합니다. 예: import sqlite3; print(sqlite3.sqlite_version)3.45.1 같은 값을 출력할 수 있습니다.

여러 프로세스가 같은 SQLite 데이터베이스를 읽을 수 있나요?

네. 여러 프로세스가 동시에 같은 SQLite 데이터베이스를 읽을 수 있습니다. 쓰기 작업은 데이터베이스 파일에 대해 배타적 락을 획득합니다. WAL 모드를 활성화하면 reader가 writer를 막지 않고 writer도 reader를 막지 않지만, writer는 동시에 하나만 가능합니다.

SQLite 데이터베이스를 안전하게 백업하려면 어떻게 해야 하나요?

sqlite3 backup API인 source.backup(dest)를 사용하세요. 또는 쓰기 작업이 진행 중이 아닐 때 데이터베이스 파일을 안전하게 복사할 수도 있습니다. 쓰기 트랜잭션이 활성화된 상태에서 파일을 복사하면 백업이 손상될 수 있으므로 절대 피해야 합니다.

import sqlite3
 
source = sqlite3.connect("myapp.db")
backup = sqlite3.connect("myapp_backup.db")
source.backup(backup)
backup.close()
source.close()

SQLite에서 날짜/시간은 어떻게 저장하나요?

SQLite에는 네이티브 DATE/DATETIME 타입이 없습니다. ISO 8601 형식의 TEXT('2026-02-18' 또는 '2026-02-18T14:30:00')로 저장하세요. SQLite의 내장 날짜 함수(date(), time(), datetime(), strftime())는 이 형식과 잘 동작합니다. 또는 Unix timestamp를 INTEGER로 저장하는 방법도 있습니다.

SQLite는 암호화를 지원하나요?

표준 오픈소스 SQLite에는 암호화가 포함되어 있지 않습니다. SQLite Encryption Extension(SEE)은 유료 상용 제품입니다. 무료 대안으로는 SQLCipher(오픈소스)와 Python 연동용 pysqlcipher3가 있습니다.

Python sqlite3에서 SQL injection을 어떻게 방지하나요?

? 또는 :name placeholder를 사용하는 매개변수화 쿼리를 항상 사용하세요. f-string, .format(), % 포맷팅으로 사용자 입력을 포함해 SQL 문자열을 구성하지 마세요. sqlite3 모듈은 파라미터를 분리해서 전달하면 이스케이프 처리를 자동으로 수행합니다.

결론

Python의 sqlite3 모듈은 설정 없이도 완전한 기능을 갖춘 관계형 데이터베이스를 제공합니다. 로컬 애플리케이션, 프로토타입, 데이터 분석 스크립트, 테스트 환경에서 SQLite는 매우 강력한 선택지입니다. 꼭 기억할 핵심 패턴은 다음과 같습니다: SQL injection을 막기 위해 항상 매개변수화 쿼리를 사용하고, 연결 관리를 깔끔히 하기 위해 컨텍스트 매니저를 사용하며, 동시성 향상을 위해 WAL 모드를 활성화하고, 대량 작업에는 executemany를 사용하세요.

데이터 분석 워크플로우에서는 SQLite와 pandas 조합이 특히 강력합니다. to_sql()로 데이터를 적재하고, read_sql()로 질의한 뒤, PyGWalker (opens in a new tab)로 시각화할 수 있습니다. 프로젝트가 SQLite의 동시성 한계를 넘어서면, DB-API 2.0 인터페이스가 드라이버 간에 일관적이기 때문에 PostgreSQL로의 전환도 보통 코드 변경이 최소화됩니다.

📚