Eiko Wagenknecht
Software Developer, Freelancer & Founder

Understanding the Anki APKG Format: Legacy 2

(updated ) Eiko Wagenknecht

This is part two of my Anki APKG format series. In part one, I covered the evolution of the format and identified three versions: 📜 Legacy 1, 🔄 Legacy 2, and ⚡ Latest.

Now I’m diving into 🔄 Legacy 2. Anki introduced this version in 2018. Most tools support it. You get this format when exporting from recent Anki versions with the “Support older Anki versions (slower/larger files)” option enabled.

This post covers:

Important

This isn’t an official spec, just what I’ve figured out through research and reverse engineering. If you spot any mistakes, please let me know and I’ll fix them.

Table of Contents

File Structure Overview

🔄 Legacy 2 APKG files are ZIP archives containing:

Only collection.anki21 uses “deflate” compression. The other files are stored uncompressed.

I explained the collection.anki2 and meta files in my previous post. So let’s focus on the main database file, collection.anki21, and the media mapping.

collection.anki21 file - The Main Database

The collection.anki21 file stores the actual deck data in a SQLite database.

It stores Anki’s key entity types across five tables. Some data lives in dedicated tables, other data is stored as JSON:

Key Entities:

Additional Entities:

Database Schema Overview

The database uses schema v11. There are five main tables, as well as JSON configuration fields in the col table:

Here’s how they relate to each other:

There are no actual foreign key constraints. All relationships are implicit.

Note

This is not the actual schema of the database. It’s a visualization hack for understanding the relationships.

Tools used: SQLite Schema Diagram Generator and Graphviz:

sqlite3 database.db -init sqlite-schema-diagram.sql "" > schema.dot
.\graphviz\bin\dot -Tsvg schema.dot > schema.svg

Let’s examine each table in detail.

col table

This single-row col table defines note types, decks, and options referenced by other tables.

CREATE TABLE
    col (
        -- "1" as there is only one collection.
        id integer PRIMARY KEY,
        -- When the collection was created (in unix time, seconds) (3).
        -- Only takes the day into account and sets the time to 04:00 local time on creation.
        crt integer NOT NULL,
        -- When the collection was last modified (in unix time, milliseconds) (3).
        mod integer NOT NULL,
        -- When the schema was last modified (in unix time, milliseconds) (3).
        scm integer NOT NULL,
        -- Schema version of the collection. This is always "11" for the "Legacy 2" format.
        ver integer NOT NULL,
        -- Dirty flag. Not used anywhere (1).
        dty integer NOT NULL,
        -- Update sequence number: Starts with 0, incremented when the collection is changed. Used for syncing.
        usn integer NOT NULL,
        -- When the collection was last synced (in unix time, milliseconds) (3).
        ls integer NOT NULL,
        -- JSON object containing configuration options (2).
        conf text NOT NULL,
        -- JSON object containing the models (note types) in the collection (2).
        models text NOT NULL,
        -- JSON object containing the decks in the collection (2).
        decks text NOT NULL,
        -- JSON object containing the deck options in the collection (2).
        dconf text NOT NULL,
        -- JSON object containing the tags in the collection (2, 4).
        tags text NOT NULL
    )

Notes:

  1. This search for the column name does not return any results.
  2. See below for a description of the JSON fields.
  3. Many ids in the database are unix timestamps, but they use different precisions. Some are in seconds, some in milliseconds. This continues throughout the database.
  4. The tags for the notes are stored in the notes.tags field. I have never seen the col.tags field with any value but an empty object ({}).

conf field

This field contains a JSON object with configuration entries. Some appear in the Anki GUI, others are technical. If I were to write them as a TypeScript interface, it would look like this:

interface Config {
  /**
   * Also known as  "ShowRemainingDueCountsInStudy" in the Anki Rust codebase.
   *
   * Anki GUI: "Preferences > Review > Show remaining card count"
   * @default true
   */
  dueCounts: boolean;
  /**
   * Timezone offset in minutes from when the collection was created.
   * Used in the scheduler to decide when the day ends.
   *
   * Must be an integer, in minutes.
   * @default The timezone offset of the system at the time of collection creation.
   * @example -120 for UTC+2
   */
  creationOffset: number;
  /**
   * Also known as "ShowIntervalsAboveAnswerButtons" in the Anki Rust codebase.
   *
   * Anki GUI: "Preferences > Review > Show next review time above answer buttons"
   * @default true
   */
  estTimes: boolean;
  /**
   * Also known as "ShowAnswerTimeInReview" in the Anki Rust codebase.
   * @default false
   */
  dayLearnFirst: boolean;
  /**
   * In which order to view to review the cards.
   *
   * Also known as "NewReviewMix" in the Anki Rust codebase.
   *
   * Anki GUI: "Deck Options > Display Order > New/review order"
   * @default 0 (NewSpread.DISTRIBUTE)
   */
  newSpread: NewSpread;
  /**
   * Also known as "SchedulerVersion" in the Anki Rust codebase.
   *
   * Despite there being a "v3" scheduler, this stays on "v2" because the v3
   * scheduler uses the same database schema as the v2 scheduler. Instead for
   * v3, "sched2021" is set to true.
   * @default 2
   */
  schedVer: SchedulerVersion;
  /**
   * If there is no more card to review now, but the next card in learning is in
   * less than collapseTime seconds, show it now.
   *
   * Also known as "LearnAheadSecs" in the Anki Rust codebase.
   *
   * Anki GUI: "Preferences > Review > Scheduler > Learn ahead limit"
   * The value is seconds, despite the UI showing it in minutes.
   * @default 1200 (20 minutes)
   */
  collapseTime: number;
  /**
   * Id of the last note type ("model") used.
   * Updated either when creating a note, or changing the note type of a note.
   *
   * Must be an integer, unix time in milliseconds.
   *
   * Note: When Anki (tested with 25.02.7) exports the collection, some value
   * is set here that does not correspond to any note type, probably a bug. On
   * the plus side, it seems to not be important for the collection.
   */
  curModel: number;
  /**
   * Whether the 2021 scheduler ("v3") is used or not.
   * @default true
   */
  sched2021: boolean;
  /**
   * Also known as "AnswerTimeLimitSecs" in the Anki Rust codebase.
   *
   * Anki GUI: "Preferences > Review > Scheduler > Timebox time limit"
   *
   * The value is in seconds, despite the UI showing it in minutes.
   * @default 0 (meaning no time limit)
   * @example 300 for 5 minutes
   */
  timeLim: number;
  /**
   * List containing the current deck id and its descendant.
   *
   * Array of deck ids (integers).
   * @default [1]
   */
  activeDecks: number[];
  /**
   * A string representing how the browser is sorted.
   *
   * Hint: Typing may not be completely accurate as the Anki code base is
   * quite complex here and I might have misinterpreted some things.
   * @default "noteFld"
   */
  sortType:
    | "noteFld" // SortField
    | "noteCrt" // NoteCreation
    | "cardMod" // CardMod
    | "cardDue" // Due
    | "cardEase" // Ease
    | "cardLapses" // Lapses
    | "cardIvl" // Interval
    | "cardReps" // Reps
    | "noteTags" // Tags
    | "template"; // Cards
  /**
   * The id of the last deck selected (during review, adding cards, changing the
   * deck of a card).
   *
   * Also known as "CurrentDeckId" in the Anki Rust codebase.
   * @default 1
   */
  curDeck: number;
  /**
   * This is the next position a card will get (currently highest position + 1).
   * Starts with 1.
   * Used to ensure that cards are seen in order in which they are added.
   *
   * Also known as "NextNewCardPosition" in the Anki Rust codebase.
   * @default 1
   */
  nextPos: number;
  /**
   * Whether new cards are sorted backwards in the card browser.
   * @default false
   */
  sortBackwards: boolean;
  /**
   * Also known as "AddingDefaultsToCurrentDeck" in the Anki Rust codebase
   *
   * Anki GUI: "Preferences > Editing > Default deck: 'When adding, default to current deck' (true) or 'Change deck depending on note type' (false)"
   * @default true
   */
  addToCur: boolean;
}

enum NewSpread {
  /** Distribute new cards throughout the review session */
  DISTRIBUTE = 0,
  /** Show all review cards before new cards */
  REVIEWS_FIRST = 1,
  /** Show all new cards before review cards */
  NEW_FIRST = 2,
}

enum SchedulerVersion {
  /** Legacy v1 scheduler */
  V1 = 1,
  /** Modern v2 scheduler (also used for v3 together with the sched2021 flag) */
  V2 = 2,
}

Sources: Ankidroid wiki and reverse-engineering.

models field

Despite its name, this field contains an array of note types. Each entry key is an id indicating when the model was created (unix time, in milliseconds). The value is a JSON object. These objects would look like this in TypeScript:

export interface NoteType {
  /**
   * When the note type was created.
   * The value is also used as the key in the `models` JSON object.
   *
   * Must be an integer >=1.
   * Anki uses unix time in milliseconds when creating the note type,
   */
  id: number;
  /**
   * The name of the note type as shown in the Anki UI.
   * @example "Basic", "Cloze", "Basic (and reversed card)", ...
   */
  name: string;
  /**
   * The type of the note (standard or cloze).
   * @default 0 (NoteTypeKind.STANDARD)
   */
  type: NoteTypeKind;
  /**
   * When the note type was last modified.
   *
   * Must be an integer, unix time in seconds.
   */
  mod: number;
  /**
   * Update sequence number: Incremented when the note type is changed.
   * Used for syncing.
   *
   * Must be an integer >= 0.
   * @default 0
   */
  usn: number;
  /**
   * Specifies which field is used for sorting notes in the browser.
   * Seems to refer to the "ord" property of the Field.
   *
   * Must be an integer >= 0.
   * @default 0
   */
  sortf: number;
  /**
   * The corresponding deck id.
   * Reference to a JSON object key in the `col.decks` column.
   * Can also be `null` if the note type is not associated with a deck.
   * @default null
   */
  did: number | null;
  /**
   * Card templates for the note type.
   */
  tmpls: Template[];
  /**
   * Fields of the note type.
   */
  flds: Field[];
  /**
   * Custom CSS that is applied to all templates.
   *
   * Hint: In the Anki GUI, this is shown below "Note Types > Cards > Styling"
   * despite being applied to all templates.
   * @default ".card {\n    font-family: arial;\n    font-size: 20px;\n    text-align: center;\n    color: black;\n    background-color: white;\n}\n"
   */
  css: string;
  /**
   * This LaTeX code is put before each LaTeX content for this note type.
   *
   * Anki GUI: "Note Types > Options > LaTeX > Header"
   * @default "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n"
   */
  latexPre: string;
  /**
   * This LaTeX code is put after each LaTeX content for this note type.
   *
   * Anki GUI: "Note Types > Options > LaTeX > Footer"
   * @default "\\end{document}"
   */
  latexPost: string;
  /**
   * Whether to convert LaTeX to SVG images.
   *
   * Anki GUI: "Note Types > Options > LaTeX > Create scalable images with dvisvgm"
   * @default false
   */
  latexsvg: boolean | null;
  /**
   * Not in use since 2021 (https://forums.ankiweb.net/t/is-req-still-used-or-present/9977)
   */
  req: [number, "any" | "all", number[]][];
  /**
   * The original kind of the note type. Only set for the original Anki note types.
   */
  originalStockKind: OriginalStockKind | null;
}

export enum NoteTypeKind {
  /** Standard note type. */
  STANDARD = 0,
  /** Cloze deletion note type. */
  CLOZE = 1,
}

enum OriginalStockKind {
  /** Unknown note type, probably not used. */
  UNKNOWN = 0,
  /** Basic note type, with one card. */
  BASIC = 1,
  /** Basic note type, with one card and a reversed card. */
  BASIC_AND_REVERSED = 2,
  /** Basic note type, with one card and optional reversed card. */
  BASIC_OPTIONAL_REVERSED = 3,
  /** Basic note type, with one card, where the user can type the answer. */
  BASIC_TYPING = 4,
  /** Cloze deletion note type, with one card per cloze deletion. */
  CLOZE = 5,
  /** Cloze deletion note type, but with images instead of text. */
  IMAGE_OCCLUSION = 6,
}

interface Template {
  /**
   * A randomly generated ID, probably between
   * -9999999999999999999n and 9999999999999999999n
   *
   * Warning: This is problematic, because the JSON parser might not be able to
   * handle such large integers and might convert them to the nearest safe
   * integer, losing precision and thus making the ID unusable.
   */
  id: number | bigint | null;
  /**
   * The name of the template as shown in the Anki UI.
   * @example "Card 1", "Card 2", "Cloze", ...
   */
  name: string;
  /**
   * Number of the template in the note type.
   * 0 is the first template, 1 the second, and so on.
   *
   * Must be an integer >= 0.
   */
  ord: number;
  /**
   * Question format.
   *
   * Anki GUI: "Note Types > Cards > Template > Front Template"
   * @example `{{Front}}`
   */
  qfmt: string;
  /**
   * Answer format.
   *
   * Anki GUI: "Note Types > Cards > Template > Back Template"
   * @example `{{Back}}`
   */
  afmt: string;
  /**
   * Browser question format.
   * This is used in the "Browse" view in Anki.
   *
   * Anki GUI: "Note Types > Cards > Options > Browser Appearance > Override front template"
   * @default ""
   */
  bqfmt: string;
  /**
   * Browser answer format.
   * This is used in the "Browse" view in Anki.
   *
   * Anki GUI: "Note Types > Cards > Options > Browser Appearance > Override back template"
   * @default ""
   */
  bafmt: string;
  /**
   * Id of the deck where cards with this template are created.
   * If this is `null`, the template is not associated with a deck.
   *
   * Anki GUI: "Note Types > Cards > Options > Deck Override"
   * @default null
   */
  did: number | null;
  /**
   * Name of the font to use in the Anki Browser.
   * When empty, the default font is used.
   *
   * Anki GUI: "Note Types > Cards > Options > Browser Appearance > Override font > Name"
   * @default ""
   */
  bfont: string;
  /**
   * Size of the font to use in the Anki Browser.
   * When 0, the default font size is used.
   *
   * Anki GUI: "Note Types > Cards > Options > Browser Appearance > Override font > Size"
   *
   * Must be an integer >= 0.
   * @default 0
   * @example 20
   */
  bsize: number;
}

export interface Field {
  /**
   * A randomly generated ID, probably between
   * -9999999999999999999n and 9999999999999999999n
   *
   * Warning: This is problematic, because the JSON parser might not be able to
   * handle such large integers and might convert them to the nearest safe
   * integer, losing precision and thus making the ID unusable.
   */
  id: number | bigint | null;
  /**
   * The name of the field as shown in the Anki UI.
   * @example "Front", "Back", "Extra", ...
   */
  name: string;
  /**
   * The order of the field in the note type.
   * 0 is the first field, 1 the second, and so on.
   *
   * Must be an integer >= 0.
   */
  ord: number;
  /**
   * Whether the field retains the value that was last added when adding new notes.
   * @default false
   */
  sticky: boolean;
  /**
   * Whether the field uses RTL script.
   *
   * Anki GUI: "Note Types > Fields > Reverse text direction (RTL)"
   * @default false
   */
  rtl: boolean;
  /**
   * The font used for editing the field in the Anki UI.
   *
   * Anki GUI: "Note Types > Fields > Editing Font > Name"
   * @default "Arial"
   */
  font: string;
  /**
   * The font size used for editing the field in the Anki UI.
   *
   * Anki GUI: "Note Types > Fields > Editing Font > Size"
   * @default 20
   */
  size: number;
  /**
   * Description of the field as shown in the Anki UI.
   *
   * Anki GUI: "Note Types > Fields > Description"
   * @example "This field contains the front side of the card."
   */
  description: string;
  /**
   * Whether the field is plain text (i.e., no HTML).
   *
   * Inverse of Anki GUI: "Note Types > Fields > Use HTML editor by default"
   * @default false
   */
  plainText: boolean;
  /**
   * Whether the field is collapsed in the Anki UI.
   *
   * Anki GUI: "Note Types > Fields > Collapsed by default"
   * @default false
   */
  collapsed: boolean;
  /**
   * Whether the field is excluded from search.
   *
   * Anki GUI: "Note Types > Fields > Exclude from unqualified searched (slower)"
   * @default false
   */
  excludeFromSearch: boolean;
  /**
   * Seems to have the same value as `ord` for the default note types.
   * @default null
   */
  tag: number | null;
  /**
   * When this is set, the field can not be deleted from the UI.
   * There seems to be no way to set this in the UI.
   * It is used for the most important parts of the default note types only.
   * @default false
   */
  preventDeletion: boolean;
}

Sources: Ankidroid wiki, NoteFieldSchema11 and reverse-engineering.

decks field

This field contains an array of decks. Each entry key is an id of either “1” (for the Default deck) or the time when the model was created (unix time, in milliseconds). The value is a JSON object. The interface for these objects would look like this in TypeScript:

export interface Deck {
  /**
   * When the deck was created.
   *
   * Must be an integer >= 1.
   * Anki uses unix time in milliseconds when creating the note type.
   * The exception is the default deck, which has the id 1.
   */
  id: number;
  /**
   * When the deck was last modified.
   *
   * Must be an integer, unix time in seconds.
   */
  mod: number;
  /**
   * The name of the deck as shown in the Anki UI.
   */
  name: string;
  /**
   * Update sequence number: Incremented when the deck is changed.
   * Used for syncing.
   *
   * Must be an integer >= 0.
   */
  usn: number;
  /**
   * Cards in the "Learn" status.
   *
   * The first number is the number of days that have passed between creation of
   * the collection and the last update of the deck.
   *
   * The second number is the number of cards seen today in this deck minus the
   * number of new cards in custom study today.
   * @default [0, 0]
   */
  lrnToday: [number, number];
  /**
   * Cards in the "Due" status.
   *
   * The first number is the number of days that have passed between creation of
   * the collection and the last update of the deck.
   *
   * The second number is the number of cards seen today in this deck minus the
   * number of new cards in custom study today.
   * @default [0, 0]
   */
  revToday: [number, number];
  /**
   * Cards in the "New" status.
   *
   * The first number is the number of days that have passed between creation of
   * the collection and the last update of the deck.
   *
   * The second number is the number of cards seen today in this deck minus the
   * number of new cards in custom study today.
   * @default [0, 0]
   */
  newToday: [number, number];
  /**
   * Supposedly unused, but present in the database.
   *
   * The first number is the number of days that have passed between creation of
   * the collection and the last update of the deck.
   * @default [0, 0]
   */
  timeToday: [number, number];
  /**
   * UI state, whether the deck is collapsed in the main window.
   * @default true
   */
  collapsed: boolean;
  /**
   * UI state, whether the deck is collapsed in the browser.
   * @default true
   */
  browserCollapsed: boolean;
  /**
   * Description of the deck as shown in the UI.
   * The string is interpreted as HTML.
   */
  desc: string;
  /**
   * Whether the deck is static or dynamic.
   * @default 0 (DeckDynamicity.STATIC)
   */
  dyn: DeckDynamicity;
  /**
   * Id of the deck configuration entry (see DeckConfig).
   */
  conf: number;
  /**
   * Extended new card limit (per day) for custom study.
   * @default 0
   */
  extendNew: number;
  /**
   * Extended maximum reviews limit (per day) for custom study.
   * @default 0
   */
  extendRev: number;
  /**
   * Maximum reviews (per day)
   * @default null
   */
  reviewLimit: number | null;
  /**
   * New card limit (per day).
   * @default null
   */
  newLimit: number | null;
  /**
   * Seems to be unused and just set to reviewLimit.
   * @default null
   */
  reviewLimitToday: number | null;
  /**
   * Seems to be unused and just set to newLimit.
   * @default null
   */
  newLimitToday: number | null;
}

export enum DeckDynamicity {
  /** The deck is static. */
  STATIC = 0,
  /** The deck is dynamic (e.g. filtered decks). */
  DYNAMIC = 1,
}

Sources: Ankidroid wiki, DeckCommonSchema11, NormalDeckSchema11, FilteredDeckSchema11 and reverse-engineering.

dconf field

This field contains an array of deck options. There is one entry for each deck option in the collection, as seen in the “Options for …” dropdown in the Anki GUI. Each entry key is an id of either “1” (for the Default deck) or the time when the model was created (unix time, in milliseconds). The value is a JSON object. These interfaces for these objects would look like this in TypeScript:

export interface DeckConfig {
  /**
   * When the deck configuration was created (in unix time, milliseconds).
   *
   * Must be an integer >= 1.
   * Anki uses unix time in milliseconds when creating the note type.
   * The exception is the default deck configuration, which has the id 1.
   */
  id: number;
  /**
   * When the deck configuration was last modified.
   *
   * Must be an integer, unix time in seconds.
   */
  mod: number;
  /**
   * The name of the deck configuration as shown in the Anki UI.
   */
  name: string;
  /**
   * Update sequence number: incremented when the deck configuration is changed.
   * Used for syncing.
   *
   * Must be an integer >= 0.
   * @default 0
   */
  usn: number;
  /**
   * The number of seconds after which to stop the timer.
   *
   * Anki GUI: "Deck Options > Timers > Maximum answer seconds"
   * @default 60 (1 minute)
   */
  maxTaken: number;
  /**
   * Whether the audio associated to a question should be played when the
   * question is shown.
   *
   * Inverse of Anki GUI: "Deck Options > Audio > Don't play automatically"
   * @default true
   */
  autoplay: boolean;
  /**
   * Whether the timer should be shown (1) or not (0).
   *
   * Anki GUI: "Deck Options > Timers > Show on-screen timer."
   *
   * Must be an integer, either 0 or 1.
   * @default 0
   */
  timer: number;
  /**
   * Whether the question audio should be included when the Replay action is
   * used while looking at the answer side of a card.
   * @default true
   */
  replayq: boolean;
  /**
   * Configuration for "new" cards.
   */
  new: {
    /**
     * Whether to bury cards related to new cards answered.
     * @default false
     */
    bury: boolean;
    /**
     * @default [1.0, 10.0]
     */
    delays: number[];
    /**
     * @default 2500
     */
    initialFactor: number;
    /**
     * @default [1, 4, 0]
     */
    ints: [number, number, number];
    /**
     * @default 1
     */
    order: number;
    /**
     * @default 20
     */
    perDay: number;
  };
  /**
   * Configuration for "review" cards.
   */
  rev: {
    /**
     * Whether to bury cards related to new cards answered.
     * @default false
     */
    bury: boolean;
    /**
     * An extra multiplier that is applied to a review card's interval when you rate it "Easy".
     * @default 1.3
     */
    ease4: number;
    /**
     * Multiplication factor applied to the intervals Anki generates.
     * @default 1.0
     */
    ivlFct: number;
    /**
     * The maximum number of days a review card will wait.
     * When reviews have reached the limit, Hard, Good and Easy will all give
     * the same delay. The shorter you set this, the greater your workload will be.
     * @default 36500
     */
    maxIvl: number;
    /**
     * Numbers of cards to review per day.
     * @default 200
     */
    perDay: number;
    /**
     * The multiplier applied to a review interval when answering "Hard".
     * @default 1.2
     */
    hardFactor: number;
  };
  /**
   * Configuration for "lapse" cards.
   */
  lapse: {
    /**
     * The list of successive delay between the learning steps of the new cards, as explained in the manual.
     * @default [10.0]
     */
    delays: number[];
    /**
     * What to do to leech cards.
     * @default 1 (LeechAction.MARK)
     */
    leechAction: LeechAction;
    /**
     * The number of times "Again" needs to be pressed on a review card before it is marked as a leech.
     * @default 8
     */
    leechFails: number;
    /**
     * The minimum interval given to a review card after answering "Again".
     * @default 1
     */
    minInt: number;
    /**
     * Percent by which to multiply the current interval when a card has lapsed.
     * @default 0.0
     */
    mult: number;
  };
  /**
   * Whether this deck is dynamic. Not clear if this takes precedence over the "dyn" property of the deck.
   * @default false
   */
  dyn: boolean;
  /**
   * When to show new cards in relation to review cards.
   *
   * Anki GUI: "Deck Options > Display Order > New/review order"
   *
   * Enum values unknown.
   * @default 0
   */
  newMix: number;
  /**
   * @default 0
   */
  newPerDayMinimum: number;
  /**
   * When to show (re)learning cards that cross a day boundary.
   *
   * Anki GUI: "Deck Options > Display Order > Interday learning/review order"
   *
   * Enum values unknown.
   * @default 0
   */
  interdayLearningMix: number;
  /**
   * Anki GUI: "Deck Options > Display Order > Review sort order"
   *
   * Enum values unknown.
   * @default 0
   */
  reviewOrder: number;
  /**
   * New card sort order
   *
   * Anki GUI: "Deck Options > Display Order > New card sort order"
   *
   * Enum values unknown.
   * @default 0
   */
  newSortOrder: number;
  /**
   * New card gather order
   *
   * Anki GUI: "Deck Options > Display Order > New card gather order"
   *
   * Enum values unknown.
   * @default 0
   */
  newGatherPriority: number;
  /**
   * Whether other learning cards of the same note with intervals > 1 day will
   * be delayed until the next day.
   *
   * Anki GUI: "Deck Options > Burying > Bury interday learning siblings"
   * @default false
   */
  buryInterdayLearning: boolean;
  /**
   * The weights to use for the FSRS algorithm.
   *
   * Anki GUI: "Deck Options > FSRS > FSRS parameters"
   * @default []
   */
  fsrsWeights: number[];
  /**
   * The desired retention rate for the FSRS algorithm.
   *
   * Value between 0 and 1, where 0 means 0% and 1 means 100%.
   *
   * Anki GUI: "Deck Options > FSRS > Desired retention rate"
   * @default 0.9
   */
  desiredRetention: number;
  /**
   * Some option of the FSRS algorithm.
   * @default ""
   */
  ignoreRevlogsBeforeDate: string;
  /**
   * Whether to stop the on-screen timer when the answer is revealed.
   * This doesn't affect statistics.
   *
   * Anki GUI: "Deck Options > Timers > Stop on-screen timer on answer"
   * @default false
   */
  stopTimerOnAnswer: boolean;
  /**
   * When auto advance is activated, the number of seconds to wait before
   * applying the question action.
   *
   * Set to 0 to disable.
   *
   * Anki GUI: "Deck Options > Auto Advance > Seconds to show question for"
   * @default 0.0
   */
  secondsToShowQuestion: number;
  /**
   * When auto advance is activated, the number of seconds to wait before
   * applying the answer action.
   *
   * Set to 0 to disable.
   *
   * Anki GUI: "Deck Options > Auto Advance > Seconds to show answer for"
   * @default 0.0
   */
  secondsToShowAnswer: number;
  /**
   * The action to perform after the question is shown, and time has elapsed.
   *
   * Anki GUI: "Deck Options > Auto Advance > Question action"
   * @default 0 (QuestionAction.SHOW_ANSWER)
   */
  questionAction: QuestionAction;
  /**
   * The action to perform after the answer is shown, and time has elapsed.
   *
   * Anki GUI: "Deck Options > Auto Advance > Answer action"
   * @default 0 (AnswerAction.BURY_CARD)
   */
  answerAction: AnswerAction;
  /**
   * Wait for audio to finish before automatically applying the question action
   * or answer action.
   *
   * Anki GUI: "Deck Options > Auto Advance > Wait for audio"
   * @default true
   */
  waitForAudio: boolean;
  /**
   * Seems to be unused.
   * @default 0.9
   */
  sm2Retention: number;
  /**
   * Unknown
   * @default ""
   */
  weightSearch: string;
}

enum LeechAction {
  /** Suspend the card. */
  SUSPEND = 0,
  /** Mark the card. */
  MARK = 1,
}

enum QuestionAction {
  /** Show answer. */
  SHOW_ANSWER = 0,
  /** Show reminder. */
  SHOW_REMINDER = 1,
}

enum AnswerAction {
  /** Bury card. */
  BURY_CARD = 0,
  /** Answer again. */
  ANSWER_AGAIN = 1,
  /** Answer good. */
  ANSWER_GOOD = 2,
  /** Answer hard. */
  ANSWER_HARD = 3,
  /** Show reminder. */
  SHOW_REMINDER = 4,
}

Sources: Ankidroid wiki, DeckConfSchema11 and reverse-engineering.

notes table

The next step in the hierarchy is the notes table. Notes contain the information used to generate cards. Each note belongs to a note type and can generate multiple cards.

CREATE TABLE
    notes (
        -- When the note was created (in unix time, milliseconds).
        id integer PRIMARY KEY,
        -- Globally unique 10 characters long, base91 encoded 64-bit number (1).
        guid text NOT NULL,
        -- Model id (see the `models` field of the `col` table).
        mid integer NOT NULL,
        -- When the note was last modified (in unix time, seconds).
        mod integer NOT NULL,
        -- Update sequence number: Incremented when the note is changed. Used for syncing.
        usn integer NOT NULL,
        -- Tags of the note, separated by spaces.
        tags text NOT NULL,
        -- Values of the fields in this note, seperated by 0x1f characters (2).
        flds text NOT NULL,
        -- A string (not an integer!) used as "Sort Field" in the "Browse" view in Anki.
        -- The usage of an integer type here is deliberate to achieve numerical sorting for integer values (3).
        sfld integer NOT NULL,
        -- Checksum, 32 bit unsigned integer consisting of the first 8 digits of the sha1 hash of the stripped first field in the `flds` column (4).
        csum integer NOT NULL,
        -- Not used (5). Cards can have flags, though, see the `cards` table.
        flags integer NOT NULL,
        -- Additional data. This is usually an empty string (6), but can be used by add-ons to store additional information.
        data text NOT NULL
    )

Notes:

  1. See the Python or Rust source for the guid generation details.
  2. Here’s a visual example of the flds separator, shown with my favorite hex editor, HxD:
  3. See the source comment about the deliberate integer usage.
  4. Checksum calculation source and utility.
  5. Always set to 0.
  6. Always an empty string.

If you need to generate Anki-style GUIDs in TypeScript, you can use the following code:

export function guid64(): string {
  return base91(getRandomInt());
}

function getRandomInt() {
  const bytes = crypto.getRandomValues(new Uint8Array(8));
  const bytesAsBigInt = new DataView(bytes.buffer).getBigUint64(0, false);
  return bytesAsBigInt;
}

function base91(num: bigint): string {
  const encodingTable =
    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";

  if (num === 0n) {
    return encodingTable.charAt(0);
  }

  let currentNum = num;
  let buf = "";

  while (currentNum) {
    const mod = currentNum % BigInt(encodingTable.length);
    currentNum = currentNum / BigInt(encodingTable.length);
    const char = encodingTable.charAt(Number(mod));
    buf = char + buf;
  }

  return buf;
}

cards table

Cards are the actual flashcards generated from notes. Depending on the note type’s card templates, multiple cards can be created from a single note.

CREATE TABLE
    cards (
        -- When the card was created (in unix time, milliseconds).
        id integer PRIMARY KEY,
        -- The corresponding note id. Reference to the `notes.id` column.
        nid integer NOT NULL,
        -- The corresponding deck id. Reference to a JSON object key in the `col.decks` column.
        did integer NOT NULL,
        -- Identifies the card template used to generate this card from the note.
        -- 0 is the first card template, 1 the second, and so on.
        -- For cloze deletions, this is the index of the cloze deletion in the note, starting at 0 (despite cloze deletions starting from c1 in the UI).
        ord integer NOT NULL,
        -- When the card was last modified (in unix time, milliseconds).
        mod integer NOT NULL,
        -- Update sequence number: Incremented when the card is changed. Used for syncing.
        usn integer NOT NULL,
        -- The card type (1).
        -- 0 = new, 1 = learn, 2 = review, 3 = relearn
        type integer NOT NULL,
        -- The queue the card is in (2).
        -- 0 = new, 1 = learn, 2 = review, 3 = daylearn, 4 = previewrepeat
        -- -1 = suspended, -2 = buried by scheduler, -3 = buried by user
        queue integer NOT NULL,
        -- This has a very different meaning for different queue values (3).
        -- For queue = 0 (new), it is the order the cards are to be studied (starting at 1).
        -- For queue = 1 (learn) and 4 (previewrepeat), it is a unix timestamp of the next time the card is due.
        -- For queue = 2 (review) and 3 (daylearn), it is the number of days since the card was created.
        -- For queue = -1, -2, -3, it is not set, because these cards are not due.
        due integer NOT NULL,
        -- Interval in days after which the card is due again.
        -- Warning: Negative values instead describe the time in seconds (!) until the card is due again.
        -- They are used in the following circumstances:
        -- The v2 scheduler uses negative values only for (re)learning cards.
        -- The v3 scheduler uses negative values only for intraday (re)learning cards.
        ivl integer NOT NULL,
        -- Ease factor of the card (part of the algorithm to calculate the next interval).
        factor integer NOT NULL,
        -- Number of times the card was reviewed.
        reps integer NOT NULL,
        -- Number of times the card was answered incorrectly after a previous correct answer.
        lapses integer NOT NULL,
        -- Number of reviews left until the card graduates (becomes a review card) (4).
        left integer NOT NULL,
        -- Original due date of the card (in unix time), before the card moved to a filtered deck.
        -- Irrelevant outside filtered decks.
        odue integer NOT NULL,
        -- Original deck id of the card (see `did` field), before the card moved to a filtered deck.
        -- Irrelevant outside filtered decks.
        odid integer NOT NULL,
        -- Flags of the card. Despite the name, it is not a bitfield, but a single integer (5).
        -- 1 = Red, 2 = Orange, 3 = Green, 4 = Blue, 5 = Pink, 6 = Turquoise, 7 = Purple
        flags integer NOT NULL,
        -- Additional data. This is usually an empty string, but can be used by add-ons to store additional information (6).
        data text NOT NULL
    )
  1. Defined in the CardType enum.
  2. Defined in the CardQueue enum.
  3. The due field meaning varies significantly by queue type. Sources: type 0, type 1, type 2, type 3, type 4.
  4. Previously this was more complicated and contained two values: The number of repetitions left today and the number of reviews left until the card graduates. The first value was multiplied by 1000 and then added to the second. This changed with scheduler v2 (source and test)
  5. See the source.
  6. For example, the FSRS scheduling addon uses it to store information like "cd": "{\"v\":\"v4.11.1\",\"seed\":8013,\"d\":6.81,\"s\":0.4}".

revlog table

The revlog table stores the complete history of card reviews.

CREATE TABLE
    revlog (
        -- When the review was done (in unix time, milliseconds).
        id integer PRIMARY KEY,
        -- The corresponding card id. Reference to the `cards.id` column.
        cid integer NOT NULL,
        -- Update sequence number: Incremented when the review is changed. Used for syncing.
        usn integer NOT NULL,
        -- How the card was answered (1 = again, 2 = hard, 3 = good, 4 = easy).
        ease integer NOT NULL,
        -- Interval in days after which the card was due again.
        -- Warning: Negative values instead describe the time in seconds (!) until the card was due again.
        ivl integer NOT NULL,
        -- Previous value of `ivl`. This is *not* the actual time since the last review.
        lastIvl integer NOT NULL,
        -- Ease factor of the card after answering.
        -- For the Anki SM-2 algorithm, this is between 1300 and 2500.
        -- For the Anki FSRS algorithm, this is between 100 and 1100, which represent the values "0 %" to "100 %" (1).
        factor integer NOT NULL,
        -- The number of milliseconds the review took. Capped at 60000 (1 minute) for scheduler v2, possibly uncappped for scheduler v3.
        time integer NOT NULL,
        -- Review type (0 = learning, 1 = review, 2 = relearning, 3 = filtered, 4 = manual, 5 = rescheduled) (2).
        -- 3 (filtered) was called "cram" or "early" in older versions.
        -- It's assigned when cards are reviewed when they are not due, or when rescheduling is disabled.
        type integer NOT NULL
    )
  1. This commit introduced the FSRS algorithm and the new ease factor range.
  2. See the review types.

graves table

This table tracks deleted items to ensure synchronization between devices.

CREATE TABLE
    graves (
        -- Update sequence number: Incremented when a card or note is deleted. Used for syncing (1).
        usn integer NOT NULL,
        -- Original ID of the deleted card, note or deck.
        oid integer NOT NULL,
        -- Type of the deleted object (0 = card, 1 = note, 2 = deck) (2).
        type integer NOT NULL
    )
  1. Contains usual positive values, despite the Ankidroid wiki saying it should be -1.
  2. See the source.

media file - Media File Mapping

The media file is a JSON file that maps media file names to their IDs:

{
  "0": "photo.jpg",
  "1": "audio.mp3",
  "2": "diagram.png"
}

Media files

Media files are stored in the archive root as files named 0, 1, 2, and so on. The media file maps these IDs to the original file names.

Example: How a Flag Quiz Becomes a Flashcard

Let’s trace how a flashcard “Which country does this flag belong to? → France” with an attached flag image is represented in the system:

  1. Deck (in col.decks): Contains the card in a “Example Deck” collection
  2. Note Type (in col.models): Defines “Basic” template with “Front” and “Back” fields
  3. Note (in notes table): Contains Which country does this flag belong to?<br><img src="Flag_of_France.webp"> and “France” separated by 0x1f
  4. Card (in cards table): References the note and uses the template Front → Back to generate the question/answer card
  5. Media Mapping (in media file): Maps the filename to an ID: {"0": "Flag_of_France.webp"}
  6. Media File: The actual flag image stored as file 0 in the archive root
  7. Reviews (in revlog table): Tracks each time you study this card

Quick Reference

TablePurposeKey FieldsImportant Notes
notesNote content and metadatamid (Reference to col.models.id)
cardsIndividual flashcardsnid (Reference to notes.id), did (Reference to col.decks.id)
revlogReview historycid (Reference to cards.id)
colCollection metadatamodels (Note types), decks (Decks), dconf (Deck Options)Single row with ID 1, see below for details
gravesDeleted itemsoid (Reference to cards.id, notes.id or col.decks.id), type (Determines which element it references)
JSON FieldContains
col.confGlobal preferences and settings
col.modelsNote types (card templates, fields)
col.decksDeck definitions and properties
col.dconfDeck options
col.tagsUnused

Conclusion

The 🔄 Legacy 2 format is Anki’s way of storing deck data. Although it has design quirks and limitations, it is the de facto standard for flashcard data.

Key takeaways for developers:

What’s Coming Next

Now that I’ve covered the 🔄 Legacy 2 format, the next parts of this series will dive deeper into the newer ⚡ Latest format.

Part 1: Overview: This post provides an overview of the Anki APKG format, its evolution, and the differences between the 📜 Legacy 1, 🔄 Legacy 2, and ⚡ Latest formats.

Part 2: The 🔄 Legacy 2 Format in Detail (this post): In the next post, I’ll cover the 🔄 Legacy 2 format in depth: the SQLite database structure, tables and their relationships, JSON configuration fields, and media file handling.

Part 3: The ⚡ Latest Format in Detail (not yet published): This covers the ⚡ Latest format including the Protobuf schema, database schema v18, and the key differences from 🔄 Legacy 2.

Part 4: APKG Format Critique (not yet published): The final will be a critique of the APKG format, its strengths and weaknesses, and my take on how a spaced repetition software could do better.

Building Better Spaced Repetition Tools

I didn’t reverse-engineer this format just for fun.

What started as figuring out how to improve my own memory turned into building a spaced repetition app. Before you think “oh great, yet another flashcard app” - I’m focused on solving the user experience and data ownership problems that existing tools haven’t addressed.

The main thing that’s stopped me from fully committing to existing tools is vendor lock-in. I want to own my learning data - the cards, decks and knowledge that I create, now and forever. Our study materials are valuable and shouldn’t be trapped in proprietary formats that make third-party development a nightmare.

Step one: Build a TypeScript library that converts between spaced repetition formats from tools like Anki, Mochi and Mnemosyne. To do it right, I need to understand exactly how these formats work.

That’s why you’re getting these detailed technical breakdowns.

Want Updates?

I’ll share progress through blog posts - more format deep-dives, implementation details, and early tool access.

Join the newsletter for updates when there’s something worth sharing. No spam, just occasional progress reports.

I’ll replace this with a proper signup form soon. For now, send that email to get added.

No Comments? No Problem.

This blog doesn't support comments, but your thoughts and questions are always welcome. Reach out through the contact details at the bottom of the page.

Support Me

If you found this page helpful and want to say thanks, you can support me here.