sqlite-utils 4.0이 공개됐습니다. 해당 프로젝트의 124번째 릴리스이자, 2020년 11월 3.0 이후 처음으로 메이저 버전이 올라간 릴리스입니다. 업그레이드 가이드에서 다루는 일부 소규모 브레이킹 체인지(breaking change) 외에도, 이번 버전에는 세 가지 주요 기능이 새롭게 추가됐습니다. 데이터베이스 마이그레이션(migration), 새로운 db.atomic() 메서드를 통한 중첩 트랜잭션(nested transaction), 그리고 복합 외래 키(compound foreign key) 지원입니다.
오늘 아침 sqlite-utils 4.0을 공개했습니다. 해당 프로젝트의 124번째 릴리스이자, 2020년 11월 3.0 이후 처음으로 메이저 버전이 올라간 릴리스입니다. 업그레이드 가이드에서 다루는 일부 소규모 브레이킹 체인지 외에도, 이번 버전에는 세 가지 주요 기능이 새롭게 추가됐습니다. 데이터베이스 마이그레이션, 새로운 db.atomic() 메서드를 통한 중첩 트랜잭션, 그리고 복합 외래 키 지원입니다.
스키마 마이그레이션은 SQLite 데이터베이스에 적용할 변경 사항을 순서대로 정의하고, 어떤 마이그레이션이 이미 적용됐는지 추적하며 미적용 항목을 실행하는 메커니즘입니다.
마이그레이션은 sqlite-utils Python 라이브러리를 사용해 Python 파일로 정의합니다. 이 라이브러리에는 강력한 table.transform() 메서드가 포함돼 있으며, SQLite의 ALTER TABLE 구문이 지원하지 않는 확장된 alter table 기능을 제공합니다.
(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() 인스턴스를 모두 적용합니다.
migrations.apply(db) 메서드를 사용하면 Python 코드에서 직접 마이그레이션을 실행할 수도 있습니다. 여러 버전에 걸쳐 자체적인 데이터베이스 스키마를 관리하는 도구를 만들 때 유용합니다. 제가 만든 LLM 도구도 몇 년째 이 패턴을 활용하고 있으며, llm/embeddings_migrations.py에서 확인할 수 있습니다.
이 패턴의 구현체 중 제가 가장 좋아하는 건 Django의 Migrations입니다. Andrew Godwin이 자신의 이전 프로젝트인 South를 기반으로 개발한 것입니다. 재미있는 사실을 하나 소개하자면, 2008년 최초의 DjangoCon에서 Andrew, Russ Keith-Magee, 그리고 저는 Schema Evolution 패널에 올라 Django의 스키마 마이그레이션에 대한 각자의 접근 방식을 경쟁적으로 발표했습니다! 당시 제 시도는 dmigrations라는 이름으로, 런던 Global Radio 팀과 함께 개발한 것이었습니다.
Django의 마이그레이션은 모델 정의를 바탕으로 자동 생성할 수 있고, 이전 버전으로 롤백하는 기능도 지원합니다. sqlite-utils의 접근 방식은 의도적으로 더 단순하게 설계됐습니다. Django와 달리 sqlite-utils은 모델 정의 방식의 ORM보다 프로그래밍 방식의 테이블 생성을 권장하기 때문에, 마이그레이션을 자동으로 생성하는 데 활용할 수 있는 수단이 없습니다.
롤백은 실제로 거의 사용되지 않는 기능이라는 경험에 따라, 과감히 포함하지 않기로 했습니다. SQLite 프로젝트에서 롤백이 필요하다면, 마이그레이션을 적용하기 전에 데이터베이스 파일을 복사해두는 것이 가장 간편한 방법입니다!
sqlite-utils 마이그레이션의 설계는 이제 3년이 됐습니다. 원래는 sqlite-migrate라는 별도 패키지로 처음 공개했는데, 베타를 벗어나지 못한 채로 남아 있었습니다.
충분히 많은 곳에서 이 패키지를 사용해보면서 설계에 확신이 생겼고, 이제 sqlite-utils의 기본 기능으로 통합하기로 했습니다. sqlite-utils/Datasette/LLM 생태계 전체에서 기본적으로 활용할 수 있도록 하기 위해서입니다.
sqlite-migrate의 마지막 릴리스를 배포하면서, sqlite-utils>=4에 대한 의존성을 추가하고 __init__.py 파일의 내용을 아래와 같이 대체했습니다.
from sqlite_utils import Migrations __all__ = ["Migrations"]
sqlite-migrate에 의존하는 기존 프로젝트는 별도 수정 없이 계속 동작합니다.
이번 버전의 릴리스 노트와 몇 가지 부가 설명을 함께 소개합니다.
4.0 릴리스에는 몇 가지 하위 호환성을 깨는 수정 사항이 포함돼 있으며(메이저 버전 번호를 올린 이유이기도 합니다), 세 가지 주요 신규 기능이 추가됐습니다.
- 데이터베이스 마이그레이션 — 프로젝트의 스키마를 시간이 지남에 따라 체계적으로 발전시킬 수 있는 구조화된 메커니즘. (#752)
마이그레이션은 이번 버전의 대표 신기능이라고 생각합니다. 이 블로그 포스트를 작성한 이유이기도 합니다.
- 중첩 트랜잭션 지원 —
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()를 중첩해 트랜잭션 안에서 또 다른 트랜잭션을 실행할 수 있습니다. 꽤 멋진 기능입니다!
- 복합 외래 키 지원 — 생성, 변환, table.foreign_keys를 통한 인트로스펙션(introspection) 포함. (#594)
이 기능은 코딩 에이전트에게 미해결 이슈와 PR을 전부 검토해 나중에 추가하면 브레이킹 체인지가 될 수 있는 항목들을 4.0 릴리스에 포함할 것을 요청하면서 시작됐습니다. 에이전트는 복합 외래 키가 바로 그런 유형의 기능임을 정확히 짚어냈습니다.
table.foreign_keys 인트로스펙션 메서드의 브레이킹 체인지를 먼저 적용한 뒤, 복합 외래 키 생성 기능을 라이브러리에 통합하는 더 까다로운 작업은 Claude Fable 5에게 맡겨봤습니다. Fable 5가 함께 설계한 API는 제가 보기에 완벽했습니다. 기존 라이브러리의 다른 부분과 일관성이 있었기 때문입니다.
그 외 주목할 만한 변경 사항은 다음과 같습니다.
- Upsert가 이제 SQLite의
INSERT ... ON CONFLICT ... DO UPDATE SET구문을 사용하며, 기존 테이블의 기본 키를 자동으로 감지하고 필수 기본 키 값이 없는 레코드는 거부합니다. (#652)
이 변경 사항이 처음으로 4.0 브레이킹 체인지 버전 번호를 고려하게 된 계기였습니다. sqlite-chronicle 지원을 위해 구현한 기능으로, 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인 경우 룩업 테이블 레코드를 생성하지 않습니다. (#186)
이번 릴리스에서 처리된 이슈 중 가장 오래된 것으로, 해당 버그는 2020년 10월(제가 직접) 등록했습니다.
하위 호환성을 깨는 변경 사항의 자세한 내용은 3.x에서 4.0으로 업그레이드하기를 참고하세요.
4.0 사전 릴리스 사이클에서 제공된 기능 및 버그 수정에 대한 상세 릴리스 노트는 4.0a0, 4.0a1, 4.0rc1, 4.0rc2, 4.0rc3, 4.0rc4에서 확인할 수 있습니다.
업그레이드 가이드는 Claude Fable 5, Claude Opus 4.8, GPT-5.5가 전담해서 작성했습니다. 릴리스 노트도 마찬가지입니다.
이런 종류의 문서는 이제 AI에게 맡겨도 된다는 확신이 서서히 생겼습니다. 독자를 설득하거나 의견을 표명할 필요가 없고, 오직 정확하고 상세하게 내용을 전달하는 것이 목적이기 때문입니다. 릴리스 노트를 꼼꼼히 검토한 결과, 정확하고 빠짐없이 작성됐다고 확인할 수 있었습니다.
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
GPT-5.5 xhigh는 Codex Desktop에서, Fable 5는 Claude Code에서 각각 시도해봤습니다.
GPT-5.5는 Python 스크립트 5개를 작성했지만 특별히 흥미로운 결과는 찾아내지 못했습니다. 최종 보고서는 여기서 확인할 수 있습니다.
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은 훨씬 완성도가 낮은 릴리스가 됐을 것이라고 확신합니다.
이 블로그의 긴 글만 보고 계신가요? /atom/everything/을 구독하면 모든 포스트를 받아보실 수 있으며, 다른 구독 옵션도 확인해보세요.