sqlite-utils 4.0, now with database schema migrations

Simon Willison

sqlite-utils 4.0, 이제 데이터베이스 스키마 마이그레이션을 지원합니다

원문은 Simon Willison님이 에 게재했습니다. 이 블로그 구독하기

오늘 아침 sqlite-utils 4.0을 출시했다. 이 프로젝트의 124번째 릴리스이자 2020년 11월 3.0 이후 첫 메이저 버전 업데이트다. (이 업그레이드 가이드에 설명된) 작지만 중요한 하위 호환성 변경 외에도, 이번 버전에서는 세 가지 주요 기능이 추가됐다: 데이터베이스 마이그레이션, 중첩 트랜잭션(새 db.atomic() 메서드를 통해), 그리고 복합 외래 키 지원이다.

sqlite-utils를 이용한 데이터베이스 스키마 마이그레이션

스키마 마이그레이션은 SQLite 데이터베이스에 적용할 변경 사항의 순서를 정의하고, 어떤 마이그레이션이 적용되었는지 추적하며 아직 적용되지 않은 마이그레이션을 실행하는 메커니즘을 제공한다.

마이그레이션은 sqlite-utils Python 라이브러리를 이용해 Python 파일에 정의하며, 이 라이브러리에는 SQLite의 ALTER TABLE 문에서는 지원하지 않는 강화된 alter table 기능을 제공하는 강력한 table.transform() 메서드가 포함되어 있다.

(table.transform()SQLite 문서에서 권장하는 패턴을 구현한 것이다—새 스키마로 임시 테이블을 새로 만들고 데이터를 복사한 뒤 기존 테이블을 삭제하고 임시 테이블의 이름을 바꾸는 방식이다.)

다음은 creatures라는 테이블을 생성하고, 두 번째 단계에서 컬럼을 하나 추가한 뒤, 세 번째 단계에서 두 컬럼의 타입을 변경하는 마이그레이션 파일 예시다:

from sqlite_utils import Migrations

migrations = Migrations("creatures")

@migrations()
def create_table(db):
    db["creatures"].create(
        {"id": int, "name": str, "species": str},
        pk="id",
    )

@migrations()
def add_weight(db):
    db["creatures"].add_column("weight", float)

@migrations()
def change_column_types(db):
    db["creatures"].transform(types={"species": int, "weight": str})

위 내용을 migrations.py로 저장한 뒤 새로운 데이터베이스에 다음과 같이 실행한다:

uvx sqlite-utils migrate data.db migrations.py

그런 다음 해당 데이터베이스의 스키마를 확인하면:

uvx sqlite-utils schema data.db

다음과 같은 SQL을 볼 수 있다:

CREATE TABLE "_sqlite_migrations" (
   "id" INTEGER PRIMARY KEY,
   "migration_set" TEXT,
   "name" TEXT,
   "applied_at" TEXT
);
CREATE UNIQUE INDEX "idx__sqlite_migrations_migration_set_name"
    ON "_sqlite_migrations" ("migration_set", "name");
CREATE TABLE "creatures" (
   "id" INTEGER PRIMARY KEY,
   "name" TEXT,
   "species" INTEGER,
   "weight" TEXT
);

_sqlite_migrations 테이블은 어떤 마이그레이션 함수가 실행되었는지 추적하는 데 사용된다. 위의 creatures 테이블은 세 마이그레이션이 모두 적용된 뒤의 스키마다.

적용된 마이그레이션과 대기 중인 마이그레이션 목록을 보려면 다음을 실행한다:

uvx sqlite-utils migrate data.db migrations.py --list

출력:

Migrations for: creatures

  Applied:
    create_table - 2026-07-07 17:58:41.360051+00:00
    add_weight - 2026-07-07 17:58:41.360608+00:00
    change_column_types - 2026-07-07 18:01:15.802000+00:00

  Pending:
    (none)

마이그레이션 파일을 지정하지 않으면, sqlite-utils migrate data.db 명령이 현재 디렉터리와 하위 디렉터리를 스캔해 migrations.py라는 파일을 찾고 그 안에서 발견된 모든 Migrations() 인스턴스를 적용한다.

Python 코드에서도 Python 코드에서 migrations.apply(db) 메서드를 이용해 마이그레이션을 실행할 수 있는데, 이는 여러 버전에 걸쳐 자체 데이터베이스 스키마를 관리하는 도구를 만들 때 유용하다. 내가 만든 LLM 도구 역시 몇 년 전부터 이 패턴을 변형해 사용하고 있으며, llm/embeddings_migrations.py에서 확인할 수 있다.

선행 사례

이 패턴의 구현 중 내가 가장 좋아하는 것은 Andrew Godwin이 이전 프로젝트인 South를 기반으로 개발한 Django Migrations다. 재미있는 사실은 2008년 첫 DjangoCon의 Schema Evolution 패널에서 Andrew와 Russ Keith-Magee, 그리고 내가 Django 스키마 마이그레이션에 대한 각자의 경쟁적 접근법을 발표했다는 점이다! 내가 시도했던 것은 런던 Global Radio 팀과 함께 개발한 dmigrations였다.

Django의 마이그레이션은 모델 정의로부터 자동으로 생성될 수 있으며 이전 버전으로 롤백하는 기능도 포함한다. sqlite-utils의 접근법은 의도적으로 더 단순하다. Django와 달리 sqlite-utils는 모델 정의 ORM보다는 프로그래밍 방식의 테이블 생성을 권장하므로, 마이그레이션을 자동으로 생성하는 데 활용할 만한 것이 없다.

경험상 롤백은 거의 사용되지 않는 기능이라 제외하기로 했다. SQLite 프로젝트에서는 마이그레이션을 적용하기 전에 데이터베이스 파일을 복사해 두는 것만으로도 롤백을 쉽게 구현할 수 있다!

sqlite-migrate에서 옮겨오기

sqlite-utils 마이그레이션의 설계는 이제 3년이 됐다. 원래는 sqlite-migrate라는 별도 패키지로 출시했었는데, 베타를 넘어 정식으로 발전하지는 못했다.

이제 충분히 많은 곳에서 그 패키지를 사용해 보며 설계에 확신이 생겼고, 그래서 이를 sqlite-utils의 기능으로 승격해 커져가는 sqlite-utils/Datasette/LLM 생태계의 모든 도구가 기본으로 사용할 수 있게 하기로 했다.

마지막 릴리스에서 sqlite-migratesqlite-utils>=4에 의존하도록 전환하고 __init__.py 파일을 다음과 같이 교체했다:

from sqlite_utils import Migrations

__all__ = ["Migrations"]

sqlite-migrate에 의존하던 기존 프로젝트는 수정 없이 계속 동작해야 한다.

sqlite-utils 4.0의 그 외 모든 것

이번 버전의 릴리스 노트와 함께 몇 가지 부가 설명을 덧붙인다:

4.0 릴리스에는 소규모이지만 하위 호환되지 않는 수정 사항(그래서 메이저 버전이 올라갔다)과 세 가지 주요 신규 기능이 포함되어 있다:

마이그레이션을 이번 릴리스의 대표 신규 기능으로 생각하기에 이 블로그 글을 쓰게 됐다.

  • 중첩 트랜잭션 지원db.atomic()을 통해 제공되며, 라이브러리 전반의 트랜잭션 동작에 대한 수많은 개선이 포함된다. (#755)

sqlite-utils는 오랫동안 데이터베이스 트랜잭션과 관련해 혼란스러운 관계를 맺어 왔다. 부분적으로는 2018년 이 라이브러리를 설계하기 시작했을 때 SQLite 자체에서 트랜잭션이 어떻게 동작하는지 제대로 감을 잡지 못했기 때문이다.

마이그레이션을 코어 라이브러리에 추가하면서 이 문제를 마침내 해결해야겠다는 결심이 섰다. 트랜잭션이 있으면 마이그레이션 시스템이 훨씬 더 안전하고 이해하기 쉬워지기 때문이다.

결국 다음과 같이 생긴 db.atomic() 컨텍스트 매니저를 중심으로 이 기능을 만들었다:

with db.atomic():
    db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
    db.table("dogs").insert({"id": 2, "name": "Pancakes"})

SQLite는 Savepoint를 지원하므로, 그 결과 db.atomic()을 중첩해 트랜잭션 안에서 트랜잭션을 실행할 수 있다. 꽤 멋지다!

이는 코딩 에이전트에게 열려 있는 모든 이슈와 PR을 검토해 4.0 릴리스에 포함되어야 할 것들을 찾아달라고 요청했을 때 생긴 일이다. 나중에 추가하면 호환성이 깨질 만한 것들을 찾으라는 요청이었는데, 에이전트는 복합 외래 키가 정확히 그런 기능이라고 올바르게 짚어냈다.

먼저 table.foreign_keys 인트로스펙션 메서드에 대한 호환성 변경부터 시작했고, 이어서 Claude Fable 5가 라이브러리에 복합 외래 키 생성을 통합하는 더 까다로운 작업을 처리할 수 있을지 시험해 보기로 했다. 에이전트가 함께 만든 API 디자인은 내게 정확히 맞아떨어진다고 느껴졌다—이미 라이브러리의 다른 부분들이 동작하는 방식과 일관됐기 때문이다.

기타 주목할 만한 변경 사항은 다음과 같다:

  • Upsert는 이제 SQLite의 INSERT ... ON CONFLICT ... DO UPDATE SET 구문을 사용하며, 기존 테이블의 기본 키를 자동으로 감지하고 필수 기본 키 값이 누락된 레코드를 거부한다. (#652)

이 변경이 호환성을 깨는 4.0 버전으로 올릴 것을 처음 고려하게 만든 계기였다. 트리거를 이용해 테이블에서 삽입, 수정 또는 삭제된 행을 추적하는 sqlite-chronicle을 지원하기 위해 만들었다.

  • db.query()는 이제 즉시 실행되며 행을 반환하지 않는 문을 거부한다. 쓰기나 DDL에는 db.execute()를 사용하라.

아마도 가장 파급력이 큰 호환성 변경일 것이다—나 역시 코드 곳곳에서 db.query()db.execute()로 교체해야 했다.

  • CSV 및 TSV 가져오기는 이제 기본적으로 컬럼 타입을 감지하며, 기존 테이블에 대한 삽입은 해당 테이블의 컬럼 타입을 유지한다. (#679)

sqlite-utils insert data.db creatures creatures.csv --detect-types 플래그는 CSV 데이터에 기반해 컬럼 타입(text, integer, real)을 자동으로 감지할 수 있게 하려는 목적으로 나중에 추가된 것이다. 원래부터 기본값이어야 했고, 4.0을 릴리스하면서 그렇게 만들 수 있게 됐다.

  • table.extract()extracts=는 더 이상 모든 값이 null인 경우에 대해 lookup 테이블 레코드를 생성하지 않는다. (#186)

이번 릴리스에서 해결된 가장 오래된 이슈로—해당 버그는 2020년 10월에 (내가) 등록한 것이다.

하위 호환되지 않는 변경 사항에 대한 자세한 내용은 3.x에서 4.0으로 업그레이드를 참고하라.

4.0 프리 릴리스 주기 동안 제공된 기능과 수정 사항에 대한 자세한 릴리스 노트는 4.0a0, 4.0a1, 4.0rc1, 4.0rc2, 4.0rc34.0rc4에서 확인할 수 있다.

업그레이드 가이드는 전적으로 Claude Fable 5, Claude Opus 4.8 및 GPT-5.5가 작성했다. 릴리스 노트도 마찬가지다.

나는 이런 종류의 문서를 로봇에게 아웃소싱하는 데 서서히 익숙해지고 있다. 누군가를 설득하거나 의견을 피력할 필요가 없고, 가능한 한 정확하고 상세하게 작성하는 것이 임무이기 때문이다. 릴리스 노트를 면밀히 검토했고, 정확하고 포괄적임을 확인할 수 있었다.

Claude Fable 5가 큰 도움이 됐다

sqlite-utils 4.0의 첫 알파를 1년여 전에 릴리스했다. 메이저 버전이 허용하는 많은 사소한 설계 결함을 찾아내고 정리하는 데 들어갈 작업량 때문에 정식 릴리스를 미루고 있었다.

Claude Fable 5(그리고 그보다 적게는 Opus 4.8과 GPT-5.5)의 도움이 관성을 극복하고 이 라이브러리에 투자할 수 있는 시간을 최대한 활용하는 데 필요한 추진력을 주었다.

Fable은 API 디자인에 정말 좋은 감각을 가지고 있고, 좀 더 개방적인 목표를 주면 끊임없이 능동적으로 움직인다. 내가 가장 성공적이었다고 생각한 프롬프트는 마지막 릴리스 후보라고 생각했던 버전에 대해 내린 리뷰 작업이었다:

review the changes on main since the last tagged 3.x release - I am about to ship them as sqlite-utils 4.0, a stable version that promises no backwards-incompatible fixes for a very long time.

review the changelog and upgrade guide, and write yourself scratch scripts to try out all of the new features in v4 - save those scripts but don't commit them

이 프롬프트를 Codex Desktop의 GPT-5.5 xhigh와 Claude Code의 Fable 5로 시도해 봤다.

GPT-5.5는 5개의 Python 스크립트를 작성했고 특별히 흥미로운 결과는 내놓지 않았다—최종 보고서는 여기에서 볼 수 있다.

Fable 5는 12개의 스크립트를 작성했고, 보고서에서 4개의 릴리스 블로커와 10개의 추가 이슈를 식별했으며(보고서), 깔끔한 통합 재현 스크립트를 만들었는데, 실행하면 다음과 같은 결과가 출력됐다:

=== 1. Failed db.execute() write leaves an implicit transaction open ===
  in_transaction after failed write: True
  BUG: table 'other' silently lost when connection closed

=== 2. Leading ';' bypasses the query() first-token scanner ===
  BUG: raised OperationalError: no such savepoint: sqlite_utils_query
  BUG: row persisted despite rollback (count=1)

=== 3. Rejected write PRAGMA via query() still takes effect ===
  BUG: user_version=5 after 'rejected' statement (docs say no effect)

=== 4. Implicit compound FK resolves pk columns in table order, not PK order ===
  BUG: other_columns reported as ('b', 'a'), should be ('a', 'b')
  BUG: transform of valid data raised IntegrityError: FOREIGN KEY constraint failed

=== 5. ForeignKey (now a dataclass) is no longer hashable ===
  BUG: cannot use 'sqlite_utils.db.ForeignKey' as a set element (unhashable type: 'ForeignKey')

=== 6. Mixed ForeignKey objects and tuples in foreign_keys= rejected ===
  BUG: foreign_keys= should be a list of tuples

=== 7. insert --csv into an EXISTING table transforms its column types ===
  BUG: existing zip '01234' is now 1234 (column type: int)

=== 8. insert(pk=, alter=True) regression: InvalidColumns before alter runs ===
  BUG: InvalidColumns: Invalid primary key column ['id'] for table t with columns ['a']

=== 9. migrate --stop-before an already-applied migration applies everything ===
  BUG: m2 was applied despite --stop-before m1 (m1 already applied)

=== 10. ensure_autocommit_on() silently commits an open transaction ===
  BUG: row survived rollback (count=1) - transaction was committed

거의 모든 지적에 동의하게 됐다. 이를 하나씩 해결해 나간 16개 커밋의 PR은 여기에서 볼 수 있다.

최신 프론티어 모델들의 도움 없이 만들었다면 sqlite-utils 4.0이 지금처럼 훨씬 더 높은 품질의 릴리스가 되지는 못했을 것임이 분명하다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글