-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Database‐Structure‐2026
Version: 002
Schema version covered: v11 (the universal compatibility baseline)
Last validated: February 2026
- Overview
-
Conventions
- 2.1 Timestamp Formats
- 2.2 Update Sequence Number (USN)
- 2.3 ID Generation
- 2.4 String Encoding
-
Tables
- 3.1 col — Collection
- 3.2 notes — Notes
- 3.3 cards — Cards
- 3.4 revlog — Review Log
- 3.5 graves — Sync Tombstones
- 3.6 Tags — Three Distinct Usages
- JSON Blobs in col
- Indexes
- Key Relationships
- Read & Write Patterns
- Known Gotchas
- Reference Sources
Anki stores all data for a collection in a single SQLite database file with the .anki2 extension. This file is found inside .apkg package files (which are ZIP archives) and also lives directly in the user's Anki data directory as the live collection.
Schema version 11 is the universal compatibility baseline: it is both the minimum version all Anki clients can open (SCHEMA_MIN_VERSION = 11) and the version new collections are initially created with (SCHEMA_STARTING_VERSION = 11). Desktop Anki internally upgrades collections to v18 for performance but downgrades back to v11 before writing .apkg exports. This means v11 is the correct target for any tool that reads or writes Anki packages.
Core entity types:
| Entity | Storage | Description |
|---|---|---|
| Collection |
col table (single row) |
Global settings, all JSON configuration |
| Notetype (Model) |
col.models JSON blob |
Defines field structure and card templates |
| Deck |
col.decks JSON blob |
Groups cards; may be hierarchical |
| Deck Config |
col.dconf JSON blob |
Scheduling parameters per deck group |
| Note |
notes table |
Raw content — one or more field values |
| Card |
cards table |
A reviewable item derived from a note |
| Review |
revlog table |
Immutable log of every review ever done |
| Grave |
graves table |
Tombstones for sync deletion propagation |
For conceptual background on notes vs cards see the Anki Key Concepts manual.
Two timestamp granularities are used throughout. The column or field name does not always make this obvious — the table below is the authoritative reference.
| Granularity | Unit | Example value | Where used |
|---|---|---|---|
| Epoch milliseconds | ms since Unix epoch | 1620000000000 |
cards.id, notes.id, revlog.id, model ids, deck ids |
| Epoch seconds | s since Unix epoch | 1620000000 |
cards.mod, notes.mod, col.mod, col.crt, col.ls, deck mod, model mod
|
Rule of thumb: id columns use milliseconds (13 digits). mod columns use seconds (10 digits).
col.crt (collection creation time) is epoch seconds, but is only accurate to the day — the hour component reflects the configured "new day starts at" hour (default 4 AM).
USN is a monotonically increasing integer used by the sync protocol to identify which records have changed since the last sync.
| Value | Meaning |
|---|---|
-1 |
Record has local changes that need to be pushed to the server |
>= 0 |
The server USN at the time this record was last synced |
When writing records locally (not during sync), always set usn = -1. The sync engine will assign real USN values during synchronisation. The col.usn field holds the collection's current server USN.
All primary key id values (notes, cards, revlog entries) must be epoch milliseconds integers that are unique within their table. The standard approach is to use the current timestamp in ms and increment by 1 if a collision is detected.
IDs for notetypes and decks follow the same convention (epoch ms) but are stored in JSON rather than as table rows. The default deck always has id = 1; the default deck config always has id = 1.
All text in the database is UTF-8. SQLite's text affinity is used throughout — there are no varchar or nchar types. Field content in notes may contain HTML.
The collection table always contains exactly one row. It holds global metadata and all configuration as JSON blobs.
CREATE TABLE col (
id integer PRIMARY KEY, -- arbitrary, always 1
crt integer NOT NULL, -- collection creation time, epoch seconds (day-accurate)
mod integer NOT NULL, -- last modification time, epoch milliseconds
scm integer NOT NULL, -- schema modification time, epoch milliseconds
ver integer NOT NULL, -- schema version, always 11 for v11 collections
dty integer NOT NULL, -- dirty flag, unused, always 0
usn integer NOT NULL, -- server USN for sync
ls integer NOT NULL, -- last sync time, epoch milliseconds
conf text NOT NULL, -- JSON: global preferences (see §4.4)
models text NOT NULL, -- JSON: all notetypes (see §4.1)
decks text NOT NULL, -- JSON: all decks (see §4.2)
dconf text NOT NULL, -- JSON: all deck configurations (see §4.3)
tags text NOT NULL -- JSON: tag cache (see §4.5)
);Column reference:
| Column | Type | Semantics |
|---|---|---|
id |
integer PK | Always 1. No meaning beyond satisfying PK constraint. |
crt |
epoch seconds | Collection creation timestamp. Used by the scheduler to compute "days since collection creation" for the due field of review cards. |
mod |
epoch ms | Updated whenever any data in the collection changes. Used by sync to detect if a full sync is needed. |
scm |
epoch ms | Updated when the schema changes (notetype added/removed, field added, etc.). If client scm differs from server scm, a full sync is required. |
ver |
integer | Schema version. Always 11 in v11 collections. |
dty |
integer | Legacy dirty flag. Always 0, never read. |
usn |
integer | The server's USN value as of the last sync. The sync protocol uses this to find records modified since last sync. |
ls |
epoch ms | Timestamp of last successful sync. |
conf |
JSON text | Global scheduler and UI preferences. See §4.4. |
models |
JSON text | All notetypes. Keys are string-encoded epoch ms IDs. See §4.1. |
decks |
JSON text | All decks. Keys are string-encoded deck IDs. See §4.2. |
dconf |
JSON text | All deck configurations. Keys are string-encoded config IDs. See §4.3. |
tags |
JSON text | Tag cache object. See §4.5. |
Initial row (empty collection):
INSERT INTO col VALUES (1, 0, 0, 0, 0, 0, 0, 0, '{}', '{}', '{}', '{}', '{}');A note stores the raw content from which one or more cards are generated. The structure of a note (which fields it has, how cards are generated from it) is determined by its notetype (mid → col.models).
CREATE TABLE notes (
id integer PRIMARY KEY, -- epoch ms, unique
guid text NOT NULL, -- globally unique ID string (for sync deduplication)
mid integer NOT NULL, -- notetype (model) ID → col.models key
mod integer NOT NULL, -- modification time, epoch seconds
usn integer NOT NULL, -- update sequence number (see §2.2)
tags text NOT NULL, -- space-separated tags with leading/trailing space
flds text NOT NULL, -- field values separated by 0x1f (unit separator)
sfld integer NOT NULL, -- sort field value (text stored as integer column for numeric sorting)
csum integer NOT NULL, -- field checksum for duplicate detection
flags integer NOT NULL, -- unused, always 0
data text NOT NULL -- unused, always ''
);Column reference:
| Column | Type | Semantics |
|---|---|---|
id |
epoch ms PK | Creation timestamp in milliseconds. Used as the note's primary identifier. |
guid |
text | Globally unique identifier string. See guid semantics below. |
mid |
integer | Foreign key into col.models (as integer, though the JSON key is a string). Identifies which notetype this note belongs to. |
mod |
epoch seconds | Last modification time. Must be updated whenever the note's content changes. |
usn |
integer | Set to -1 on local writes. See §2.2. |
tags |
text | Space-separated list of tags with a leading and trailing space: " tag1 tag2 ". This format enables LIKE '% tagname %' queries. See §3.6. |
flds |
text | All field values concatenated with the ASCII unit separator \x1f (decimal 31). Values only — no field names. See flds semantics below. |
sfld |
integer | Denormalized copy of the sort field value. See sfld semantics below. |
csum |
integer | Checksum of the first field for fast duplicate detection. Computed as the integer representation of the first 8 hex digits of the SHA-1 hash of the first field's value (stripped of HTML). When writing, setting csum = 0 is acceptable — Anki recomputes it on import. |
flags |
integer | Reserved, currently unused. Always write 0. Confirmed unused in notes.py: flags = 0 is a hardcoded class-level default. |
data |
text | Reserved, currently unused. Always write ''. Confirmed unused in notes.py: data = "" is a hardcoded class-level default. |
guid semantics:
guid is a NOT NULL text field that serves as the stable cross-device identity of a note, used by the sync and import engines for deduplication. Two distinct strategies exist depending on who creates the note:
| Creator | Strategy | Reproducible? |
|---|---|---|
| genanki |
guid_for(*field_values) — stable hash of all field values |
✅ Yes — same fields always produce same guid |
| Anki backend |
guid64() — random base64 string assigned at creation |
❌ No — random, but stable after creation |
The genanki approach means re-exporting the same content produces the same guids, so Anki treats reimported notes as updates to existing ones rather than duplicates. The backend approach produces guids that are stable post-creation but not derivable from content.
Critical: guid is NOT NULL enforced at the DB level. An .apkg with missing guids will fail SQLite constraint on import. When writing notes programmatically, always generate a guid. For the .apkg import path, guid is the sole matching key — see §8.2.
flds semantics:
flds stores field values only — field names are stored exclusively in the notetype definition (col.models). The mapping between values and field names is purely positional:
flds value: "Question text\x1fAnswer text\x1fHint text"
↕ ↕ ↕
notetype: flds[0].ord=0 flds[1].ord=1 flds[2].ord=2
name="Front" name="Back" name="Hint"
The contract: flds.split('\x1f')[n] maps to the field whose ord == n in notetype["flds"]. Template variables like {{Front}} resolve by: field name → field["ord"] → flds[ord].
Silent corruption risk: if the order of values in flds does not match the ord sequence in the notetype, wrong values will render in templates with no error. This is the most common source of data corruption when building notes programmatically.
# Correct: values in ord order
flds = '\x1f'.join([
fields_by_name["Front"], # ord 0
fields_by_name["Back"], # ord 1
])
# Reading: always split, then index by ord
values = flds_string.split('\x1f')
front_value = values[field_map["Front"][0]] # field_map[name] = (ord, field_dict)Cross-reference: see §4.1 for the notetype flds array structure.
sfld semantics:
sfld is a denormalized copy of one field's value, extracted from flds for performance. It is not an index or pointer — it is the actual text content of whichever field is designated as the sort field (notetype["sortf"]).
notetype["sortf"] = 0 → sfld = flds.split('\x1f')[0]
notetype["sortf"] = 1 → sfld = flds.split('\x1f')[1]
The column has integer affinity in the DDL — this is intentional and load-bearing. From the source comment in schema11.sql:
"The use of type integer for sfld is deliberate, because it means that integer values in this field will sort numerically."
SQLite stores the text value faithfully regardless of affinity, but when the value is a pure integer string (e.g. "42"), comparisons and ORDER BY treat it numerically. This allows vocabulary lists with numeric indices to sort correctly without any special handling.
sfld is used for: browser "Sort Field" column display and sorting. It is not used for duplicate detection — that is csum.
sfld is computed and written by the backend on note save. When writing .apkg files, genanki sets it to fields[model.sort_field_index] directly. Do not cast to int when reading — treat as text.
Field encoding detail:
# Writing
flds_value = '\x1f'.join(field_values) # e.g. "Question\x1fAnswer"
# Reading
field_values = flds_value.split('\x1f') # ["Question", "Answer"]Tag encoding detail:
# Writing
tags_value = ' ' + ' '.join(tags) + ' ' # " tag1 tag2 "
# Reading
tags = tags_value.strip().split() # ["tag1", "tag2"]
# Querying a specific tag
cursor.execute("SELECT * FROM notes WHERE tags LIKE ?", (f'% {tag} %',))csum algorithm:
import hashlib
def field_checksum(first_field: str) -> int:
# Strip HTML tags before hashing
import re
plain = re.sub(r'<[^>]+>', '', first_field)
sha = hashlib.sha1(plain.encode('utf-8')).hexdigest()
return int(sha[:8], 16)Cards are the reviewable units derived from notes. A note may generate one or more cards depending on its notetype's templates. Each card tracks its own scheduling state independently.
CREATE TABLE cards (
id integer PRIMARY KEY, -- epoch ms, unique
nid integer NOT NULL, -- note ID → notes.id
did integer NOT NULL, -- deck ID → col.decks key
ord integer NOT NULL, -- template index (0-based)
mod integer NOT NULL, -- modification time, epoch seconds
usn integer NOT NULL, -- update sequence number (see §2.2)
type integer NOT NULL, -- card type (0–3)
queue integer NOT NULL, -- scheduling queue (-3 to 4)
due integer NOT NULL, -- due value (semantics depend on type)
ivl integer NOT NULL, -- interval in days (0 for learning cards)
factor integer NOT NULL, -- ease factor in permille
reps integer NOT NULL, -- total number of reviews
lapses integer NOT NULL, -- number of times card went from correct to incorrect
left integer NOT NULL, -- remaining learning steps (encoded)
odue integer NOT NULL, -- original due (for filtered decks)
odid integer NOT NULL, -- original deck ID (for filtered decks)
flags integer NOT NULL, -- user flag (low 3 bits) and reserved bits
data text NOT NULL -- unused, always ''
);Column reference:
| Column | Python attr | Protobuf field | Semantics |
|---|---|---|---|
id |
card.id |
card.id |
Epoch ms creation timestamp, primary key. |
nid |
card.nid |
card.note_id |
Parent note. |
did |
card.did |
card.deck_id |
Current deck. For cards in filtered decks, this points to the filtered deck — use odid or did for the card's "home" deck. |
ord |
card.ord |
card.template_idx |
0-based index into notetype["tmpls"] for standard notetypes. For cloze notetypes, the 0-based cloze index ({{c1::}} = ord 0). |
mod |
card.mod |
card.mtime_secs |
Epoch seconds, updated on every scheduling change. |
usn |
card.usn |
card.usn |
Set to -1 on local writes. |
type |
card.type |
card.ctype |
Card type enum — see below. |
queue |
card.queue |
card.queue |
Scheduling queue enum — see below. |
due |
card.due |
card.due |
Due value — semantics vary by type — see below. |
ivl |
card.ivl |
card.interval |
Review interval in days. 0 for new and intraday learning cards. For relearning cards, the interval the card will receive after graduating through all lapse steps. |
factor |
card.factor |
card.ease_factor |
Ease factor in permille. Default 2500 (= 2.5×). Valid range: 1300–9999. A factor of 2500 means next interval = current interval × 2.5. |
reps |
card.reps |
card.reps |
Total number of reviews ever, including relearning. |
lapses |
card.lapses |
card.lapses |
Number of times the card transitioned from a correct state to an incorrect (Again) answer. Used for leech detection. |
left |
card.left |
card.remaining_steps |
Encoded as a * 1000 + b where a = steps remaining today, b = total steps remaining until graduation. Example: 2004 = 2 steps today, 4 steps total. |
odue |
card.odue |
card.original_due |
Original due value before the card was moved to a filtered deck. Restored when the card leaves the filtered deck. 0 when not in a filtered deck. |
odid |
card.odid |
card.original_deck_id |
Original deck ID before moving to a filtered deck. 0 when not in a filtered deck. |
flags |
card.flags |
card.flags |
Low 3 bits (flags & 0b111) = user flag color: 0=none, 1=red, 2=orange, 3=green, 4=blue, 5=pink, 6=turquoise, 7=purple. Upper bits reserved. |
data |
card.custom_data |
card.custom_data |
Unused in v11. Always write ''. |
type enum — Card Type:
| Value | Constant | Meaning |
|---|---|---|
0 |
CARD_TYPE_NEW |
Card has never been reviewed. |
1 |
CARD_TYPE_LRN |
Card is in the initial learning phase. |
2 |
CARD_TYPE_REV |
Card has graduated to the review phase. |
3 |
CARD_TYPE_RELEARNING |
Card lapsed and is relearning. |
queue enum — Scheduling Queue:
| Value | Constant | Meaning |
|---|---|---|
-3 |
QUEUE_TYPE_MANUALLY_BURIED |
Buried by user action; unseen until next day. |
-2 |
QUEUE_TYPE_SIBLING_BURIED |
Auto-buried because a sibling card was reviewed; unseen until next day. |
-1 |
QUEUE_TYPE_SUSPENDED |
Suspended by user; excluded from all study until manually unsuspended. |
0 |
QUEUE_TYPE_NEW |
Waiting in the new card queue. |
1 |
QUEUE_TYPE_LRN |
In learning, next review due within the same day (intraday). |
2 |
QUEUE_TYPE_REV |
In the review queue; due on a future day. |
3 |
QUEUE_TYPE_DAY_LEARN_RELEARN |
In learning or relearning, but next step is due on a different day (interday learning). |
4 |
QUEUE_TYPE_PREVIEW |
In preview mode (filtered deck preview). |
due field — Three distinct semantics by card type:
queue value |
due meaning |
Unit |
|---|---|---|
0 (new) |
Position in the new card queue. Lower = shown sooner. Starts at 1. | integer position |
1 (learning intraday) |
Absolute timestamp when this step is due. | epoch seconds |
2 (review) |
Days since the collection's creation date (col.crt) when this card is due. |
integer days |
3 (interday learning) |
Days since col.crt when the next step is due. |
integer days |
Computing "days since collection creation":
import time
days_since_creation = int((time.time() - col_crt) / 86400)Cards in filtered decks: due holds the card's position within the filtered deck. odue preserves the original due value.
factor valid range:
The default starting factor is STARTING_FACTOR = 2500. The minimum is 1300 (enforced by the scheduler). Values below 1300 surviving in the DB (e.g. manually set) are clamped on next review.
left decoding:
steps_today = left // 1000
steps_total = left % 1000flags bitmask:
# Read user flag
flag_color = card.flags & 0b111 # 0–7
# Set user flag (preserving other bits)
card.flags = (card.flags & ~0b111) | flag_colorDetermining a card's home deck:
home_deck_id = card.odid if card.odid else card.didAn append-only log of every review ever performed. One row per review event. Never updated after insertion.
CREATE TABLE revlog (
id integer PRIMARY KEY, -- epoch ms of the review
cid integer NOT NULL, -- card ID → cards.id
usn integer NOT NULL, -- update sequence number
ease integer NOT NULL, -- button pressed (1–4)
ivl integer NOT NULL, -- resulting interval (signed)
lastIvl integer NOT NULL, -- interval before this review (signed)
factor integer NOT NULL, -- ease factor after review, permille
time integer NOT NULL, -- time spent answering, milliseconds (capped at 60000)
type integer NOT NULL -- review type (0–4)
);Column reference:
| Column | Semantics |
|---|---|
id |
Epoch milliseconds timestamp of the review. Serves as both PK and review time. |
cid |
The card that was reviewed. |
usn |
-1 for unsynced reviews. |
ease |
Which answer button was pressed. Values differ by review type — see below. |
ivl |
The resulting interval after this review. Positive = days. Negative = seconds (used for intraday learning steps). |
lastIvl |
The interval before this review. Same sign convention as ivl. Note: this is not necessarily the elapsed time since the previous review — it is the scheduled interval value. |
factor |
The card's ease factor in permille immediately after this review. |
time |
How long the user spent looking at the card before answering, in milliseconds. Capped at 60000 (60 seconds) regardless of actual time taken. |
type |
The type of review event — see below. |
ease values by context:
| Review context | Button 1 | Button 2 | Button 3 | Button 4 |
|---|---|---|---|---|
Review (REVLOG_REV) |
Again | Hard | Good | Easy |
| Learning/Relearning | Again | Good | Easy | — |
type enum — Review Type:
| Value | Constant | Meaning |
|---|---|---|
0 |
REVLOG_LRN |
Initial learning phase review. |
1 |
REVLOG_REV |
Regular scheduled review. |
2 |
REVLOG_RELRN |
Relearning after a lapse. |
3 |
REVLOG_CRAM |
Review in a filtered (cram) deck. |
4 |
REVLOG_RESCHED |
Manual reschedule. |
ivl sign convention:
ivl > 0 → interval in days (review and interday learning cards)
ivl < 0 → interval in seconds, negated (intraday learning steps)
Example: ivl = -600 means the card was due again in 600 seconds (10 minutes)
ivl = 7 means the card was due again in 7 days
Records deletions so that the sync protocol can propagate them to other devices. A row is inserted here whenever a card, note, or deck is deleted locally.
CREATE TABLE graves (
usn integer NOT NULL, -- -1 for unsynced tombstones
oid integer NOT NULL, -- original ID of the deleted entity
type integer NOT NULL -- entity type (0, 1, or 2)
);Column reference:
| Column | Semantics |
|---|---|
usn |
-1 for tombstones not yet pushed to server. Updated to the server USN after sync. |
oid |
The id of the deleted entity (card id, note id, or deck id). |
type |
0 = REM_CARD, 1 = REM_NOTE, 2 = REM_DECK
|
Notes on usage: Graves are consumed and cleared by the sync engine after successfully pushing to the server. When writing tools create or import data, they do not need to write to graves. When deleting entities in a tool that will sync, a row must be inserted here.
The word "tags" appears in three separate locations in the schema, each with a different role and authority level. Conflating them is a common source of bugs.
| Location | Format | Role |
|---|---|---|
notes.tags |
" tag1 tag2 " (space-padded string) |
Source of truth. The actual tags belonging to each note. |
col.tags |
{"tag": usn, ...} JSON object |
Derived cache. Built by scanning notes.tags; used by the UI for autocomplete and by sync. May lag behind reality. Never use as the authoritative tag list. |
col.models[x]["tags"] |
[] (always empty array) |
Unused legacy. Anki historically stored last-used tags here. Always initialize as []. Never write to it; never read from it for tag logic. |
notes.tags — format details:
The string must have a leading and trailing space: " tag1 tag2 ". Individual tags must not contain spaces. This format is required for the LIKE '% tagname %' query pattern used throughout Anki's codebase:
-- Correct: finds notes with exactly 'spanish' tag (won't match 'spanish-verbs')
SELECT id FROM notes WHERE tags LIKE '% spanish %';Round-trip in Python:
# List → DB string
tag_list = ["spanish", "verb"]
tags_str = " " + " ".join(tag_list) + " " # " spanish verb "
# DB string → list
tag_list = tags_str.strip().split()col.tags — cache maintenance:
When writing notes programmatically, you do not need to update col.tags — Anki rebuilds it on open. If writing to a live collection (not .apkg), update it anyway for consistency:
row = cursor.execute('SELECT tags FROM col').fetchone()
tag_cache = json.loads(row[0])
for tag in new_tags:
if tag not in tag_cache:
tag_cache[tag] = -1 # usn = -1 (unsynced)
cursor.execute('UPDATE col SET tags = ?', (json.dumps(tag_cache),))Cross-reference: col.tags JSON structure is documented in §4.5.
All configuration in col is stored as JSON text in four blob columns: models, decks, dconf, and conf. Each must be read, modified, and written back atomically as a complete JSON string.
Read-modify-write pattern (required for all blob updates):
import json
# Read
row = cursor.execute('SELECT models FROM col').fetchone()
models = json.loads(row[0])
# Modify
models[str(model_id)] = new_model_dict
# Write back
cursor.execute('UPDATE col SET models = ?', (json.dumps(models),))A JSON object mapping string-encoded notetype IDs to notetype objects.
{
"1620000000000": { ...notetype object... },
"1620000000001": { ...notetype object... }
}Outer dict: keys are the notetype ID as a string (epoch ms). This is a common source of bugs — the key must be a string even though the id field inside is also stored as a string.
| Field | Type | Semantics |
|---|---|---|
id |
string | Notetype ID as a string (epoch ms). Must match the outer dict key. |
name |
string | Human-readable notetype name. Must be unique within the collection. |
type |
integer |
0 = MODEL_STD (standard front/back), 1 = MODEL_CLOZE (cloze deletion). |
mod |
integer | Modification time, epoch seconds. |
usn |
integer |
-1 for unsynced. |
did |
integer | Default deck ID for new notes of this type. |
sortf |
integer | 0-based index into flds indicating which field is used for sorting in the browser. |
flds |
array | Ordered list of field definition objects. See below. |
tmpls |
array | Ordered list of template objects. See below. |
css |
string | CSS stylesheet shared by all templates of this notetype. |
latexPre |
string | LaTeX preamble prepended to LaTeX expressions. |
latexPost |
string | LaTeX postamble appended to LaTeX expressions. Default: \\end{document}. |
latexsvg |
boolean | Whether to render LaTeX as SVG. Default: false. |
req |
array | Legacy required-field specification. See below. |
tags |
array | Always []. Anki saves the last-used tags here; initialize as empty. |
vers |
array | Legacy version array. Always []. |
Default LaTeX preamble:
\documentclass[12pt]{article}
\special{papersize=3in,5in}
\usepackage[utf8]{inputenc}
\usepackage{amssymb,amsmath}
\pagestyle{empty}
\setlength{\parindent}{0in}
\begin{document}
| Field | Type | Default | Semantics |
|---|---|---|---|
name |
string | required | Field name. Must be unique within the notetype. |
ord |
integer | assigned on save | 0-based position of this field. Assigned by Anki on save; set to null before adding. |
font |
string | "Liberation Sans" |
Display font in the editor. |
size |
integer | 20 |
Font size in the editor. |
rtl |
boolean | false |
Whether this field uses right-to-left text direction. |
sticky |
boolean | false |
If true, the field retains its value when adding a new note of the same type. |
media |
array | [] |
Legacy media references. Always []. |
| Field | Type | Default | Semantics |
|---|---|---|---|
name |
string | required | Template name (e.g. "Card 1"). |
ord |
integer | assigned on save | 0-based template index. Assigned by Anki on save. |
qfmt |
string | required | Question (front) template string using Mustache-style {{FieldName}} syntax. |
afmt |
string | required | Answer (back) template string. Use {{FrontSide}} to include the rendered front. |
bqfmt |
string | "" |
Browser question format. Empty string uses a stripped version of qfmt. |
bafmt |
string | "" |
Browser answer format. Empty string uses a stripped version of afmt. |
bfont |
string | "" |
Browser font override. Empty string uses the field's font. |
bsize |
integer | 0 |
Browser font size override. 0 uses the field's size. |
did |
integer|null | null |
Deck override for cards of this template. null means use the notetype's default deck. |
Legacy field used to determine which cards to generate. Format:
[
[template_ord, "all"|"any", [field_ord, field_ord, ...]],
...
]-
"all"— all listed fields must be non-empty for this card to be generated -
"any"— at least one listed field must be non-empty -
"none"— no card is generated (empty field list)
This field is written by genanki for compatibility but is not used by modern Anki clients (deprecated as of AnkiDroid 2.15+). Modern clients recompute card generation from the templates directly. When writing, compute it or omit it — Anki will regenerate it.
A JSON object mapping string-encoded deck IDs to deck objects.
{
"1": { ...default deck... },
"1620000000000": { ...user deck... }
}The default deck always has id 1. Its key in the outer dict is the string "1".
| Field | Type | Default | Semantics |
|---|---|---|---|
id |
integer | required | Deck ID (epoch ms for user decks, 1 for default). Stored as integer inside the object (unlike model id which is a string). |
name |
string | required | Deck name. Hierarchy is encoded using :: as separator: "Parent::Child::Grandchild". |
desc |
string | "" |
Deck description. May contain HTML. |
mod |
integer | required | Modification time, epoch seconds. |
usn |
integer | -1 |
Update sequence number. |
conf |
integer | 1 |
ID of the deck configuration (dconf) that applies to this deck. |
dyn |
integer | 0 |
0 = normal deck, 1 = filtered (dynamic) deck. |
collapsed |
boolean | false |
Whether the deck is collapsed in the deck list. |
browserCollapsed |
boolean | false |
Whether the deck is collapsed in the card browser. Optional — defaults to false when absent. |
extendNew |
integer | 0 |
Extended new card limit for custom study. |
extendRev |
integer | 50 |
Extended review card limit for custom study. |
newToday |
array | [0, 0] |
[days_elapsed, new_cards_seen_today]. days_elapsed is days since collection creation. |
revToday |
array | [0, 0] |
[days_elapsed, review_cards_seen_today]. |
lrnToday |
array | [0, 0] |
[days_elapsed, learning_cards_seen_today]. |
timeToday |
array | [0, 0] |
[days_elapsed, ms_spent_studying_today]. |
Deck hierarchy: Parent decks must exist before child decks. The name "Languages::French" implies a parent deck named "Languages" must also be present in col.decks.
Filtered decks have dyn = 1 and differ in structure:
- No
conffield (they have embedded scheduling config) - Have a
termsfield: array of search term specifications -
odidon cards in this deck is non-zero;oduestores the pre-filter due
When a card is moved into a filtered deck:
-
cards.odidis set to the card's originalcards.did -
cards.odueis set to the card's originalcards.due -
cards.didis updated to the filtered deck's ID -
cards.dueis set to the card's position in the filtered deck
A JSON object mapping string-encoded config IDs to deck configuration objects. The default config always has id 1.
{
"1": { ...default config... },
"1620000000000": { ...custom config... }
}| Field | Type | Semantics |
|---|---|---|
id |
integer | Config ID. |
name |
string | Config name shown in the UI. |
mod |
integer | Modification time, epoch seconds. |
usn |
integer | Update sequence number. |
maxTaken |
integer | Maximum seconds before the review timer stops. Default: 60. |
timer |
integer |
1 = show timer during review, 0 = hide. |
autoplay |
boolean | Whether to autoplay audio on the question side. |
replayq |
boolean | Whether to replay question audio when showing the answer. |
dyn |
boolean | Whether this is a dynamic deck config. Normally absent. |
new |
object | New card scheduling config. See below. |
rev |
object | Review card scheduling config. See below. |
lapse |
object | Lapse (relearning) config. See below. |
| Field | Type | Semantics |
|---|---|---|
order |
integer |
0 = NEW_CARDS_RANDOM, 1 = NEW_CARDS_DUE (in order added). |
perDay |
integer | Maximum new cards to show per day. |
delays |
array | Learning step delays in minutes, e.g. [1, 10]. |
ints |
array | Post-learning intervals in days: [graduating_interval, easy_interval, unused]. |
initialFactor |
integer | Starting ease factor in permille. Default: 2500. |
bury |
boolean | Whether to bury sibling cards when a new card is answered. |
separate |
boolean | Legacy, unused. |
| Field | Type | Semantics |
|---|---|---|
perDay |
integer | Maximum reviews to show per day. |
ease4 |
float | Bonus added to ease factor when Easy is pressed. Default: 1.3. |
fuzz |
float | Random variance multiplier applied to review intervals to prevent bunching. |
ivlFct |
float | Global multiplier applied to all computed review intervals. |
maxIvl |
integer | Maximum review interval in days. |
bury |
boolean | Whether to bury sibling review cards when one is answered. |
minSpace |
integer | Minimum days between related card reviews. Legacy, unused. |
| Field | Type | Semantics |
|---|---|---|
delays |
array | Relearning step delays in minutes, e.g. [10]. |
mult |
float | Multiplier applied to the interval when a card lapses. Default: 0.0. |
minInt |
integer | Minimum new interval after a lapse, in days. |
leechFails |
integer | Number of lapses before leech action is triggered. |
leechAction |
integer |
0 = LEECH_SUSPEND, 1 = LEECH_TAGONLY. |
A single JSON object (not a dict-of-objects like the others) holding global scheduler preferences and UI state.
| Field | Type | Semantics |
|---|---|---|
curDeck |
integer | ID of the currently selected deck. |
activeDecks |
array | List of the current deck ID and all its descendant deck IDs. |
newSpread |
integer | New card distribution: 0=NEW_CARDS_DISTRIBUTE (mix with reviews), 1=NEW_CARDS_LAST, 2=NEW_CARDS_FIRST. |
collapseTime |
integer | "Learn ahead limit" in seconds. If no reviews remain but the next learning card is due within this many seconds, show it now. |
timeLim |
integer | Timebox time limit in seconds. 0 disables. |
estTimes |
boolean | Show estimated next review times above answer buttons. |
dueCounts |
boolean | Show remaining card count during review. |
curModel |
string | ID (as string) of the last-used notetype. |
nextPos |
integer | The highest due value assigned to a new card. Used to assign sequential positions to newly added cards. |
sortType |
string | Browser sort column identifier. |
sortBackwards |
boolean | Whether browser sort is descending. |
addToCur |
boolean |
true = new cards go to the current deck; false = deck follows notetype default. |
dayLearnFirst |
boolean | Show interday learning cards before reviews. |
newBury |
boolean | Always true. Legacy. |
Add-on extensibility: col.conf may contain additional keys added by Anki add-ons. When modifying this object, always read the existing value first and preserve any unknown keys.
A JSON object mapping tag names to their USN values. Used to populate the tag list in the browser UI.
{
"vocabulary": -1,
"grammar": -1,
"chapter-1": -1
}Values are USN integers (-1 for unsynced). This is a cache — it is rebuilt from notes.tags when needed. When adding notes with new tags programmatically, add the tag names here with value -1.
All indexes in the v11 schema and their purpose:
| Index | Columns | Purpose |
|---|---|---|
ix_notes_usn |
notes(usn) |
Sync: quickly find notes modified since last sync (where usn = -1 or usn < server_usn). |
ix_cards_usn |
cards(usn) |
Sync: same as above for cards. |
ix_revlog_usn |
revlog(usn) |
Sync: same as above for review log entries. |
ix_cards_nid |
cards(nid) |
Note lookup: find all cards belonging to a note. Used when editing or deleting a note. |
ix_cards_sched |
cards(did, queue, due) |
Scheduling: the primary index for the scheduler's card selection query. Covers the common WHERE did = ? AND queue = ? ORDER BY due pattern. |
ix_revlog_cid |
revlog(cid) |
Card history: find all review history for a given card. Used in card statistics. |
ix_notes_csum |
notes(csum) |
Duplicate detection: find notes with the same first-field checksum before adding a new note. |
The scheduling query that uses ix_cards_sched:
SELECT id FROM cards
WHERE did IN (active_deck_ids)
AND queue = 2 -- review queue
ORDER BY due
LIMIT n;col (1 row)
├── col.models (JSON) ──────────────────── notes.mid
│ └── notetypes
│ ├── flds[] → note.flds (positional)
│ ├── tmpls[] → cards.ord (index)
│ └── sortf → notes.sfld (field value)
│
├── col.decks (JSON) ──────────────────── cards.did / cards.odid
│ └── decks
│ └── conf ──────────────────────┐
│ │
├── col.dconf (JSON) ←───────────────────┘ (deck.conf → dconf.id)
│ └── deck configs
│
└── col.tags (JSON) ← cache of notes.tags
notes ──────────────────────────────────────── notes.id = cards.nid
└── 1 note → N cards (one per template, or per cloze index)
cards ──────────────────────────────────────── cards.id = revlog.cid
└── 1 card → N revlog entries (one per review)
graves
└── tombstones for deleted cards (type=0), notes (type=1), decks (type=2)
Foreign key constraints are not enforced by SQLite (and are not declared in the schema). Referential integrity is the responsibility of the application.
-- Get due review cards for active decks
SELECT id FROM cards
WHERE did IN (1, 2, 3) -- active deck IDs from col.conf.activeDecks
AND queue = 2 -- QUEUE_TYPE_REV
ORDER BY due -- days since col.crt
LIMIT 100;
-- Get due learning cards (intraday)
SELECT id FROM cards
WHERE did IN (1, 2, 3)
AND queue = 1 -- QUEUE_TYPE_LRN
AND due <= ? -- current epoch seconds
ORDER BY due;
-- Get new cards
SELECT id FROM cards
WHERE did IN (1, 2, 3)
AND queue = 0 -- QUEUE_TYPE_NEW
ORDER BY due -- positional order
LIMIT ?; -- perDay limitimport time, hashlib, re, random
def add_note_and_cards(cursor, notetype, field_values, deck_id, tags=None):
tags = tags or []
now_ms = int(time.time() * 1000)
now_s = int(time.time())
# 1. Compute derived note fields
flds = '\x1f'.join(field_values)
sfld = field_values[notetype['sortf']]
plain = re.sub(r'<[^>]+>', '', field_values[0])
csum = int(hashlib.sha1(plain.encode()).hexdigest()[:8], 16)
tags_s = ' ' + ' '.join(tags) + ' ' if tags else ' '
guid = generate_guid() # stable unique string, e.g. base91 of random bytes
# 2. Insert note
note_id = now_ms # ensure uniqueness
cursor.execute(
'INSERT INTO notes VALUES (?,?,?,?,?,?,?,?,?,?,?)',
(note_id, guid, notetype['id'], now_s, -1,
tags_s, flds, sfld, csum, 0, '')
)
# 3. Insert cards (one per template for standard notetypes)
for tmpl in notetype['tmpls']:
card_id = note_id + tmpl['ord'] + 1 # ensure uniqueness
cursor.execute(
'INSERT INTO cards VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
(card_id, note_id, deck_id, tmpl['ord'],
now_s, -1,
0, # type = CARD_TYPE_NEW
0, # queue = QUEUE_TYPE_NEW
0, # due = position (set after; see nextPos)
0, # ivl
2500, # factor = STARTING_FACTOR
0, # reps
0, # lapses
0, # left
0, # odue
0, # odid
0, # flags
'') # data
)Always use read-modify-write. Never overwrite the entire blob without reading first (other keys may exist).
def add_notetype(cursor, notetype_dict):
row = cursor.execute('SELECT models FROM col').fetchone()
models = json.loads(row[0])
model_id = str(notetype_dict['id'])
models[model_id] = notetype_dict
cursor.execute('UPDATE col SET models = ?', (json.dumps(models),))
def add_deck(cursor, deck_dict):
row = cursor.execute('SELECT decks FROM col').fetchone()
decks = json.loads(row[0])
deck_id = str(deck_dict['id'])
decks[deck_id] = deck_dict
cursor.execute('UPDATE col SET decks = ?', (json.dumps(decks),))# Local write (not during sync):
record['usn'] = -1
# After sync, the sync engine sets:
record['usn'] = server_usn # the current server USN
# Finding unsynced records:
cursor.execute('SELECT id FROM cards WHERE usn = -1')
cursor.execute('SELECT id FROM notes WHERE usn = -1')mod must be updated whenever a record's content changes:
import time
card.mod = int(time.time()) # epoch seconds
note.mod = int(time.time()) # epoch seconds
# col.mod is epoch milliseconds:
cursor.execute('UPDATE col SET mod = ?', (int(time.time() * 1000),))Understanding when cards are generated is critical for .apkg tooling.
For .apkg files (genanki / custom writers):
Cards are generated at package creation time, not at Anki import time. The .apkg archive contains a fully populated cards table. Anki imports these pre-generated card rows directly.
write time: notes → card generation → cards table → .apkg ZIP
import time: .apkg cards table → INSERT into collection (no regeneration)
Standard notetype card generation:
One card per template, gated by the req field. For each note:
for tmpl in notetype["tmpls"]:
# req is a list of [template_ord, "any"|"all", [required_field_ords]]
# A card is generated if the required fields are non-empty
if fields_satisfy_req(field_values, notetype["req"], tmpl["ord"]):
generate_card(note, tmpl)In practice, genanki generates one card per template unconditionally and lets Anki suppress display if fields are empty.
Cloze notetype card generation:
One card per unique {{cN::}} index found across all field values:
import re
def cloze_ords(field_values):
ords = set()
for value in field_values:
for m in re.finditer(r'{{c(\d+)::', value):
# syntax is 1-indexed (c1, c2...), card ord is 0-indexed
ords.add(int(m.group(1)) - 1)
return sorted(ords)
# Example: "{{c1::front}} {{c2::back}}" → ords [0, 1] → two cardsAnki's generate_missing_cards (Rust importer):
When importing via the text/CSV path (ForeignData.import), Anki calls generate_cards_for_existing_note after inserting the note — so cards are generated server-side in that path. For .apkg imports, the cards table is read directly and no generation occurs unless a card is missing.
Practical rule: If writing .apkg files, always populate the cards table. Do not rely on Anki to generate cards at import time.
1. id as string vs integer in JSON blobs
Model IDs are strings in col.models keys AND in the id field inside the model object. Deck IDs are strings in col.decks keys but integers inside the deck object. Mixing these up causes silent failures.
# Correct: model key is string, inner id is string
models[str(model_id)] = {"id": str(model_id), ...}
# Correct: deck key is string, inner id is integer
decks[str(deck_id)] = {"id": deck_id, ...} # int, not str2. csum = 0 is acceptable on write
Anki recomputes csum on import. Setting it to 0 is safe for tools that write .apkg files. For tools that write directly to a live collection, compute it properly to enable duplicate detection.
3. due semantics change by card type
The same due column means different things for different queue values. Always check queue before interpreting due.
4. Use odid or did for a card's home deck
cards.did points to the current deck, which may be a filtered deck. The card's original deck is cards.odid (when non-zero). For deck-based operations, always resolve: home = card.odid or card.did.
5. Cloze ord is 0-indexed; cloze syntax is 1-indexed
{{c1::text}} generates a card with ord = 0. {{c2::text}} generates ord = 1. The offset is always 1.
6. col.conf may have add-on keys
Third-party add-ons write arbitrary keys into col.conf. Always read the existing object first and merge your changes rather than replacing the whole object.
7. Notetype id is stored as both the JSON key and an inner field, both as strings
When reading: models["1620000000000"]["id"] → "1620000000000" (string). When using it as a DB foreign key (notes.mid), cast to int: int(notetype["id"]).
8. sfld column type is integer but stores text
SQLite's column affinity for sfld is integer, but text values are stored faithfully. This is intentional — numeric strings sort numerically. Do not cast to int when reading; treat as text.
9. Filtered deck cards have two did values
cards.did = filtered deck id while in the filtered deck. cards.odid = original deck id. Queries that count cards "in a deck" must check both: WHERE did = ? OR odid = ?.
10. tags in notes requires padding
The leading and trailing spaces in notes.tags are load-bearing. Without them, LIKE '% tag %' queries fail for tags at the start or end of the string.
11. flds is positional — field names are not stored in notes
notes.flds contains values only. The mapping to field names is via notetype["flds"][n]["ord"]. If values are inserted in the wrong order, wrong content will render in templates with no error. See §3.2 for the full positional contract and §4.1 for the notetype flds array structure.
The .apkg import path (Rust: rslib/src/import_export/package/apkg/import/notes.rs) uses guid as the sole matching key. This differs from the text/CSV import path (noteimp.py) which matches on first-field checksum. Do not conflate them.
Decision tree for each incoming note:
incoming note
│
├─ guid found in existing collection?
│ │
│ ├─ NO → add_note() — inserted as new (id uniquified if collision)
│ │
│ └─ YES → maybe_update_existing_note()
│ │
│ ├─ notetype_id differs AND merge_notetypes=false
│ │ → log_conflicting() — SILENTLY SKIPPED, note unchanged
│ │
│ ├─ incoming.mtime <= existing.mtime (UpdateCondition::IfNewer)
│ │ → log_duplicate() — SILENTLY SKIPPED, note unchanged
│ │
│ └─ otherwise → update_note() — fields, tags updated
Failure mode 1 — Conflicting notetype id:
If the incoming note's notetype_id does not match the existing note's notetype_id, the note is logged as conflicting and skipped. The existing note is left untouched. No error is raised to the user by default.
This happens when:
- You deleted and recreated the notetype in genanki (new timestamp → new id)
- A previous import with a schema mismatch caused Anki to duplicate the notetype with a new id
-
remapped_notetypesmaps the incoming id to a different target id
Failure mode 2 — Stale mtime:
With the default UpdateCondition::IfNewer, if the incoming note's mod timestamp is equal to or older than the existing note's mod, the note is logged as duplicate and skipped.
This happens when re-exporting a collection without touching notes: the mod timestamps are unchanged, so no updates are written.
# Force mtime forward to guarantee update on reimport:
note.mod = int(time.time())The fix — merge_notetypes=True:
# Python API (direct collection access):
col.import_apkg(path, ImportAnkiPackageOptions(merge_notetypes=True))With merge_notetypes=True, conflicting notetypes are merged field-by-field rather than causing a skip. Fields are remapped by ord, new fields are appended. After merge, the sibling notetype may be deleted if unused.
Key architectural fact: noteimp.py (first-field + checksum matching) is the text/CSV import path only. It is not involved in .apkg import. The guid path in Rust is authoritative for .apkg.
Confirmed by Rust test:
fn should_ignore_note_if_guid_already_exists_with_different_notetype() {
note.notetype_id.0 = 42; // changed notetype id
// result: logged as conflicting, original note untouched
assert_note_logged!(ctx.imports.log, conflicting, &["updated", ""]);
assert_eq!(col.get_all_notes()[0].fields()[0], "");
}Summary table:
| Condition | Result | Note changed? |
|---|---|---|
| guid absent in collection | Added as new | — |
| guid match + same notetype + newer mtime | Updated | ✅ |
| guid match + same notetype + same/older mtime | Duplicate (skipped) | ❌ |
guid match + different notetype + merge_notetypes=false
|
Conflicting (skipped) | ❌ |
guid match + different notetype + merge_notetypes=true
|
Merged and updated | ✅ |
All sources below were directly read and validated during the preparation of this document unless marked otherwise.
| Source | URL | What it confirms |
|---|---|---|
schema11.sql |
https://github.com/ankitects/anki/blob/main/rslib/src/storage/schema11.sql | Ground-truth DDL, sfld integer type rationale comment |
upgrades/mod.rs |
https://github.com/ankitects/anki/blob/main/rslib/src/storage/upgrades/mod.rs | v11 as min/starting version, upgrade chain skips v12–v13, ease_factor roundtrip test |
pylib/anki/models.py |
https://github.com/ankitects/anki/blob/main/pylib/anki/models.py | Notetype dict structure, field/template operations, scmhash algorithm |
pylib/anki/decks.py |
https://github.com/ankitects/anki/blob/main/pylib/anki/decks.py | Deck dict structure, maxTaken as dconf discriminator, browserCollapsed optional default, DEFAULT_DECK_ID=1, DEFAULT_DECK_CONF_ID=1
|
pylib/anki/cards.py |
https://github.com/ankitects/anki/blob/main/pylib/anki/cards.py | All column↔attr↔proto mappings, flags & 0b111 bitmask, odid or did home deck logic, dconf key names |
pylib/anki/notes.py |
https://github.com/ankitects/anki/blob/main/pylib/anki/notes.py |
flds join/split, tags list↔string, flags=0 and data="" as permanent defaults, sfld as sort field value |
pylib/anki/consts.py |
https://github.com/ankitects/anki/blob/main/pylib/anki/consts.py | All enum values: CardType, CardQueue, REVLOG_*, REM_*, MODEL_*, DECK_*, STARTING_FACTOR=2500
|
genanki/model.py |
https://github.com/kerrickstaley/genanki/blob/main/genanki/model.py | Complete notetype JSON shape, all field defaults, template bfont/bsize fields, req algorithm |
genanki/deck.py |
https://github.com/kerrickstaley/genanki/blob/main/genanki/deck.py | Complete deck JSON shape, write pattern for col.models and col.decks blobs |
genanki/note.py |
https://github.com/kerrickstaley/genanki/blob/main/genanki/note.py |
notes INSERT column order, \x1f separator, tag space-padding, csum=0 acceptable, cloze card ord generation |
genanki/apkg_schema.py |
https://github.com/kerrickstaley/genanki/blob/main/genanki/apkg_schema.py | Cross-reference DDL with column position comments, schema identity with schema11.sql confirmed |
rslib/.../text/import.rs |
https://github.com/ankitects/anki/blob/main/rslib/src/import_export/text/import.rs | Text/CSV import path: two-path deduplication (guid vs checksum+first-field), DupeResolution enum, field zip truncation, conflicting notetype handling |
rslib/.../apkg/import/notes.rs |
https://github.com/ankitects/anki/blob/main/rslib/src/import_export/package/apkg/import/notes.rs |
.apkg import path: guid-only matching, maybe_update_existing_note, merge_notetypes logic, UpdateCondition::IfNewer, notetype schema conflict resolution, confirmed by inline Rust tests |
pylib/anki/importing/noteimp.py |
https://github.com/ankitects/anki/blob/main/pylib/anki/importing/noteimp.py | Text import deduplication (first field + csum, NOT guid), UPDATE_MODE default, field name mapping via processFields
|
| Source | URL | Notes |
|---|---|---|
genanki/card.py |
https://github.com/kerrickstaley/genanki/blob/main/genanki/card.py | Card write logic; column order inferred from note.py
|
genanki/util.py |
https://github.com/kerrickstaley/genanki/blob/main/genanki/util.py |
guid_for() implementation |
| Source | URL |
|---|---|
| Anki Key Concepts | https://docs.ankiweb.net/getting-started.html#key-concepts |
| Anki Deck Options Manual | https://docs.ankiweb.net/deck-options.html |
| SQLite Datatypes | https://www.sqlite.org/datatype3.html |
| AnkiDroid Planned DB Changes | https://github.com/ankidroid/Anki-Android/wiki/Database-Structure-Planned-Changes.md |
Help us make AnkiDroid better: Sponsor AnkiDroid on GitHub Sponsors 💜