{"id":15432874,"url":"https://github.com/simonw/sqlite-history","last_synced_at":"2026-03-07T11:32:03.025Z","repository":{"id":152133663,"uuid":"625395502","full_name":"simonw/sqlite-history","owner":"simonw","description":"Track changes to SQLite tables using triggers","archived":false,"fork":false,"pushed_at":"2023-08-12T22:53:12.000Z","size":36,"stargazers_count":125,"open_issues_count":8,"forks_count":4,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-11-27T22:39:10.453Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/simonw.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2023-04-09T01:19:26.000Z","updated_at":"2025-10-19T19:25:05.000Z","dependencies_parsed_at":null,"dependency_job_id":"95064a02-a81f-4538-b6d3-d00c689d6932","html_url":"https://github.com/simonw/sqlite-history","commit_stats":{"total_commits":11,"total_committers":1,"mean_commits":11.0,"dds":0.0,"last_synced_commit":"dfbac50e04bbc45b719fde5342803fee5d74a80b"},"previous_names":[],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/simonw/sqlite-history","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simonw%2Fsqlite-history","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simonw%2Fsqlite-history/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simonw%2Fsqlite-history/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simonw%2Fsqlite-history/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/simonw","download_url":"https://codeload.github.com/simonw/sqlite-history/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simonw%2Fsqlite-history/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":30212124,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-07T09:02:10.694Z","status":"ssl_error","status_checked_at":"2026-03-07T09:02:08.429Z","response_time":53,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-01T18:28:57.025Z","updated_at":"2026-03-07T11:32:03.005Z","avatar_url":"https://github.com/simonw.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# sqlite-history\n\n[![PyPI](https://img.shields.io/pypi/v/sqlite-history.svg)](https://pypi.org/project/sqlite-history/)\n[![Tests](https://github.com/simonw/sqlite-history/workflows/Test/badge.svg)](https://github.com/simonw/sqlite-history/actions?query=workflow%3ATest)\n[![Changelog](https://img.shields.io/github/v/release/simonw/sqlite-history?include_prereleases\u0026label=changelog)](https://github.com/simonw/sqlite-history/releases)\n[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/sqlite-history/blob/main/LICENSE)\n\nTrack changes to SQLite tables using triggers\n\nFor more on this project: [sqlite-history: tracking changes to SQLite tables using triggers](https://simonwillison.net/2023/Apr/15/sqlite-history/)\n\n## Installation\n\nInstall this library using `pip`:\n\n    pip install sqlite-history\n\n## Usage\n\nThis library can be used to configure triggers on a SQLite database such that any inserts, updates or deletes against a table will have their changes recorded in a separate table.\n\nYou can enable history tracking for a table using the `configure_history()` function:\n\n    import sqlite_history\n    import sqlite3\n\n    conn = sqlite3.connect(\"data.db\")\n    conn.execute(\"CREATE TABLE table1 (id INTEGER PRIMARY KEY, name TEXT)\")\n    sqlite_history.configure_history(conn, \"table1\")\n\nOr you can use the CLI interface, available via `python -m sqlite_history`:\n\n    python -m sqlite_history data.db table1 [table2 table3 ...]\n\nUse `--all` to configure it for all tables:\n\n    python -m sqlite_history data.db --all\n\n## How this works\n\nGiven a table with the following schema:\n\n\u003c!-- [[[cog\nimport cog\ncreate_table_sql = \"\"\"\nCREATE TABLE people (\n    id INTEGER PRIMARY KEY,\n    name TEXT,\n    age INTEGER,\n    weight REAL\n);\n\"\"\".strip()\ncog.out(\n    \"```sql\\n{}\\n```\".format(create_table_sql)\n)\n]]] --\u003e\n```sql\nCREATE TABLE people (\n    id INTEGER PRIMARY KEY,\n    name TEXT,\n    age INTEGER,\n    weight REAL\n);\n```\n\u003c!-- [[[end]]] --\u003e\n\nThis library will create a new table called `_people_history` with the following schema:\n\n\u003c!-- [[[cog\nfrom sqlite_history import sql\nimport sqlite3\ndb = sqlite3.connect(\":memory:\")\ndb.execute(create_table_sql)\ncolumns_and_types = sql.table_columns_and_types(db, \"people\")\nhistory_schema = sql.history_table_sql(\"people\", columns_and_types)\ncog.out(\n    \"```sql\\n{}\\n```\".format(history_schema.strip())\n)\n]]] --\u003e\n```sql\nCREATE TABLE _people_history (\n    _rowid INTEGER,\n    id INTEGER,\n    name TEXT,\n    age INTEGER,\n    weight REAL,\n    _version INTEGER,\n    _updated INTEGER,\n    _mask INTEGER\n);\nCREATE INDEX idx_people_history_rowid ON _people_history (_rowid);\n```\n\u003c!-- [[[end]]] --\u003e\nThe `_rowid` column references the `rowid` of the row in the original table that is being tracked. If a row has been updated multiple times there will be multiple rows with the same `_rowid` in this table.\n\nThe `id`, `name`, `age` and `weight` columns represent the new values assigned to the row when it was updated. These can also be `null`, which might represent no change or might represent the value being set to `null` (hence the `_mask` column).\n\nThe `_version` column is a monotonically increasing integer that is incremented each time a row is updated.\n\nThe `_updated` column is a timestamp showing when the change was recorded. This is stored in milliseconds since the Unix epoch - to convert that to a human-readable UTC date you can use `strftime('%Y-%m-%d %H:%M:%S', _updated / 1000, 'unixepoch')` in your SQL queries.\n\nThe `_mask` column is a bit mask that indicates which columns changed in an update. The bit mask is calculated by adding together the following values:\n\n    1: id\n    2: name\n    4: age\n    8: weight\n\nTables with different schemas will have different `_mask` values.\n\nA `_mask` of `-1` indicates that the row was deleted.\n\nThe following triggers are created to populate the `_people_history` table:\n\u003c!-- [[[cog\ntriggers_sql = sql.triggers_sql(\"people\", [c[0] for c in columns_and_types])\ncog.out(\n    \"```sql\\n{}\\n```\".format(triggers_sql.strip())\n)\n]]] --\u003e\n```sql\nCREATE TRIGGER people_insert_history\nAFTER INSERT ON people\nBEGIN\n    INSERT INTO _people_history (_rowid, id, name, age, weight, _version, _updated, _mask)\n    VALUES (new.rowid, new.id, new.name, new.age, new.weight, 1, cast((julianday('now') - 2440587.5) * 86400 * 1000 as integer), 15);\nEND;\n\nCREATE TRIGGER people_update_history\nAFTER UPDATE ON people\nFOR EACH ROW\nBEGIN\n    INSERT INTO _people_history (_rowid, id, name, age, weight, _version, _updated, _mask)\n    SELECT old.rowid, \n        CASE WHEN old.id != new.id then new.id else null end, \n        CASE WHEN old.name != new.name then new.name else null end, \n        CASE WHEN old.age != new.age then new.age else null end, \n        CASE WHEN old.weight != new.weight then new.weight else null end,\n        (SELECT MAX(_version) FROM _people_history WHERE _rowid = old.rowid) + 1,\n        cast((julianday('now') - 2440587.5) * 86400 * 1000 as integer),\n        (CASE WHEN old.id != new.id then 1 else 0 end) + (CASE WHEN old.name != new.name then 2 else 0 end) + (CASE WHEN old.age != new.age then 4 else 0 end) + (CASE WHEN old.weight != new.weight then 8 else 0 end)\n    WHERE old.id != new.id or old.name != new.name or old.age != new.age or old.weight != new.weight;\nEND;\n\nCREATE TRIGGER people_delete_history\nAFTER DELETE ON people\nBEGIN\n    INSERT INTO _people_history (_rowid, id, name, age, weight, _version, _updated, _mask)\n    VALUES (\n        old.rowid,\n        old.id, old.name, old.age, old.weight,\n        (SELECT COALESCE(MAX(_version), 0) from _people_history WHERE _rowid = old.rowid) + 1,\n        cast((julianday('now') - 2440587.5) * 86400 * 1000 as integer),\n        -1\n    );\nEND;\n```\n\u003c!-- [[[end]]] --\u003e\n\n## Development\n\nTo contribute to this library, first checkout the code. Then create a new virtual environment:\n\n    cd sqlite-history\n    python -m venv venv\n    source venv/bin/activate\n\nNow install the dependencies and test dependencies:\n\n    pip install -e '.[test]'\n\nTo run the tests:\n\n    pytest\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsimonw%2Fsqlite-history","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsimonw%2Fsqlite-history","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsimonw%2Fsqlite-history/lists"}