Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save gwpl/c88c04e7a9c648c49b81a148f07850dd to your computer and use it in GitHub Desktop.

Select an option

Save gwpl/c88c04e7a9c648c49b81a148f07850dd to your computer and use it in GitHub Desktop.
AnkiConnect: Setup & Integration Guide for Agents and Programmers — install, API basics, card CRUD, deduplication rules (with official docs references), TSV failover import, stats/scheduling queries

AnkiConnect: Setup & Integration Guide for Agents and Programmers

Practical guide to programmatically managing Anki flashcards via the AnkiConnect addon HTTP API.

Covers: installation, verification, card CRUD operations, statistics queries, deduplication behavior (with official documentation references), and batch workflows.

Contents

File Topic
01_installation.md Install AnkiConnect addon + verify
02_api-basics.md API protocol, request format, error handling
03_card-operations.md Add cards, find notes, query info, update, delete
04_deduplication-and-import.md Deduplication rules with official Anki docs references
05_stats-and-scheduling.md Query deck stats, card scheduling state, review history
06_deck-operations.md Create, list, rename (workaround), delete, move cards between decks
07_moving-splitting-merging-decks.md Split/merge decks safely — scheduling preservation proof with official docs references

Quick Start

# 1. Install AnkiConnect (see 01_installation.md)
# 2. Verify:
curl -s http://localhost:8765 -d '{"action":"version","version":6}'
# → {"result": 6, "error": null}

# 3. Add a card:
curl -s http://localhost:8765 -d '{
  "action": "addNote",
  "version": 6,
  "params": {
    "note": {
      "deckName": "Default",
      "modelName": "Basic",
      "fields": {"Front": "What is 2+2?", "Back": "4"},
      "tags": ["math", "test"]
    }
  }
}'
# → {"result": 1234567890123, "error": null}

References

License

This gist is public domain. Use freely.

01 — Installing AnkiConnect

Prerequisites

  • Anki Desktop installed — https://apps.ankiweb.net/
    • Linux: pacman -S anki (Arch), apt install anki (Debian/Ubuntu), or download from website
    • macOS/Windows: download from https://apps.ankiweb.net/
  • Anki version 2.1.x or newer (AnkiConnect requires Qt6 builds for latest versions)

Install AnkiConnect Addon

  1. Open Anki Desktop
  2. Go to Tools → Add-ons → Get Add-ons...
  3. Enter addon code: 2055492159
  4. Click OK
  5. Restart Anki (required — addon loads on startup)

The addon starts an HTTP server on 127.0.0.1:8765 whenever Anki is running.

Source references:

Verify Installation

curl -s http://localhost:8765 -d '{"action":"version","version":6}'

Expected output:

{"result": 6, "error": null}

If you get Connection refused, check:

  • Is Anki Desktop running?
  • Is AnkiConnect visible in Tools → Add-ons?
  • Did you restart Anki after installing the addon?
  • Is port 8765 blocked by a firewall?

Configuration (Optional)

AnkiConnect config is accessible via Tools → Add-ons → AnkiConnect → Config.

Default config:

{
    "apiKey": null,
    "apiLogPath": null,
    "ignoreOriginList": [],
    "webBindAddress": "127.0.0.1",
    "webBindPort": 8765,
    "webCorsOriginList": ["http://localhost"]
}

Key settings:

  • webBindPort — change if 8765 conflicts with another service
  • webCorsOriginList — add origins if calling from browser extensions
  • apiKey — set a key to require authentication (null = no auth)

Docker / CI Usage

For headless environments (Docker, CI), Anki can run with:

QT_QPA_PLATFORM=offscreen anki &

Use a different port to avoid conflicts with local Anki:

# In AnkiConnect config or via environment
export ANKI_CONNECT_PORT=28765

02 — API Basics

Protocol

AnkiConnect exposes a JSON-RPC-style HTTP API on http://localhost:8765.

  • Method: POST (always)
  • Content-Type: application/json
  • Body: JSON with action, version, and optional params

Request Format

{
    "action": "actionName",
    "version": 6,
    "params": {
        "key": "value"
    }
}
  • action — the API method name (string)
  • version — protocol version, always 6 for current AnkiConnect
  • params — action-specific parameters (object, optional)

Response Format

{
    "result": <any>,
    "error": null
}
  • result — the return value (type depends on action)
  • errornull on success, error message string on failure

Always check error firstresult may be null even on success (e.g., createDeck).

Error Handling

{"result": null, "error": "collection is not available"}
{"result": null, "error": "cannot create note because it is a duplicate"}
{"result": null, "error": "model was not found: NonExistentModel"}

Common errors:

Error Cause
Connection refused Anki not running or AnkiConnect not installed
collection is not available Anki is open but profile not loaded
cannot create note because it is a duplicate Note with same first field exists
model was not found Invalid modelName
deck was not found Deck doesn't exist (use createDeck first)

curl Examples

# Simple — no params
curl -s http://localhost:8765 -d '{"action":"version","version":6}'

# With params
curl -s http://localhost:8765 -d '{
  "action":"deckNames",
  "version":6
}'

# Pretty-print with python
curl -s http://localhost:8765 -d '{"action":"deckNames","version":6}' | python3 -m json.tool

Python Example

import json
from urllib.request import Request, urlopen

def anki_request(action, params=None, port=8765):
    payload = {"action": action, "version": 6}
    if params:
        payload["params"] = params
    req = Request(
        f"http://localhost:{port}",
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
    )
    with urlopen(req, timeout=10) as resp:
        data = json.loads(resp.read())
    if data.get("error"):
        raise Exception(data["error"])
    return data["result"]

# Usage:
version = anki_request("version")
decks = anki_request("deckNames")

Multi-Action Requests

AnkiConnect supports batching multiple actions in one request:

{
    "action": "multi",
    "version": 6,
    "params": {
        "actions": [
            {"action": "deckNames"},
            {"action": "modelNames"}
        ]
    }
}

Returns an array of results in the same order.

Key Concepts

Anki Concept Description
Note A knowledge unit with fields (Front, Back, etc.) — one note can generate multiple cards
Card A reviewable item generated from a note — has scheduling state (due date, interval, etc.)
Deck A collection of cards — each card belongs to exactly one deck
Model (Note Type) Defines fields and card templates — e.g., "Basic" has Front/Back fields
Tag Metadata label on notes (not cards) — space-separated in import files

Important: Notes and cards are different things. A "Basic" note creates 1 card. A "Basic (and reversed card)" note creates 2 cards (forward + reverse). API operations target either notes (content) or cards (scheduling).

03 — Card Operations (Add, Find, Query, Update, Delete)

Create a Deck

curl -s http://localhost:8765 -d '{
  "action": "createDeck",
  "version": 6,
  "params": {"deck": "MyDeck::SubDeck"}
}'
# → {"result": 1234567890, "error": null}

Use :: for nested decks. Idempotent — safe to call multiple times.

Add a Single Card

curl -s http://localhost:8765 -d '{
  "action": "addNote",
  "version": 6,
  "params": {
    "note": {
      "deckName": "MyDeck",
      "modelName": "Basic",
      "fields": {
        "Front": "Wie viele Kantone hat die Schweiz?",
        "Back": "26 Kantone"
      },
      "tags": ["geography", "facts"],
      "options": {
        "allowDuplicate": false,
        "duplicateScope": "deck",
        "duplicateScopeOptions": {
          "deckName": "MyDeck",
          "checkChildren": true
        }
      }
    }
  }
}'
# → {"result": 1773005026062, "error": null}   ← note ID

Fields support HTML: "Back": "26 Kantone<br>(in 4 Sprachregionen)" renders as two lines.

Duplicate prevention: Set allowDuplicate: false — AnkiConnect checks the first field within the specified scope.

Add Multiple Cards (Batch)

curl -s http://localhost:8765 -d '{
  "action": "addNotes",
  "version": 6,
  "params": {
    "notes": [
      {
        "deckName": "MyDeck",
        "modelName": "Basic",
        "fields": {"Front": "Q1", "Back": "A1"},
        "tags": ["batch"]
      },
      {
        "deckName": "MyDeck",
        "modelName": "Basic",
        "fields": {"Front": "Q2", "Back": "A2"},
        "tags": ["batch"]
      }
    ]
  }
}'
# → {"result": [1234567890, 1234567891], "error": null}
# null in array = that note failed (e.g. duplicate)

Find Notes

# All notes in a deck
curl -s http://localhost:8765 -d '{
  "action": "findNotes",
  "version": 6,
  "params": {"query": "deck:MyDeck"}
}'
# → {"result": [1773005026062, 1773005026101, ...], "error": null}

# By tag
curl -s http://localhost:8765 -d '{
  "action": "findNotes",
  "version": 6,
  "params": {"query": "tag:geography"}
}'

# By content (searches all fields)
curl -s http://localhost:8765 -d '{
  "action": "findNotes",
  "version": 6,
  "params": {"query": "deck:MyDeck Bundesrat"}
}'

Query syntax follows Anki's search syntax:

Query Matches
deck:MyDeck All notes in deck
deck:MyDeck::SubDeck Notes in subdeck
tag:facts Notes with tag "facts"
"front:exact text" Exact match on Front field
is:new New (unreviewed) cards
is:due Cards due for review
is:review Cards in review queue
introduced:1 Cards first seen today
rated:7 Cards reviewed in last 7 days

Get Note Details

curl -s http://localhost:8765 -d '{
  "action": "notesInfo",
  "version": 6,
  "params": {"notes": [1773005026062]}
}'

Returns: noteId, modelName, tags, fields (with values), cards (card IDs).

Get Card Details (Scheduling State)

# First, find card IDs
curl -s http://localhost:8765 -d '{
  "action": "findCards",
  "version": 6,
  "params": {"query": "deck:MyDeck"}
}'

# Then get card info
curl -s http://localhost:8765 -d '{
  "action": "cardsInfo",
  "version": 6,
  "params": {"cards": [1773005026064]}
}'

Returns per card: cardId, deckName, fields, queue, type, due, interval, reps, lapses.

Update a Note

curl -s http://localhost:8765 -d '{
  "action": "updateNoteFields",
  "version": 6,
  "params": {
    "note": {
      "id": 1773005026062,
      "fields": {
        "Back": "26 Kantone<br>(6 davon sind Halbkantone)"
      }
    }
  }
}'

Only specified fields are updated. Scheduling is preserved.

Add/Remove Tags

# Add tags
curl -s http://localhost:8765 -d '{
  "action": "addTags",
  "version": 6,
  "params": {
    "notes": [1773005026062],
    "tags": "reviewed verified"
  }
}'

# Remove tags
curl -s http://localhost:8765 -d '{
  "action": "removeTags",
  "version": 6,
  "params": {
    "notes": [1773005026062],
    "tags": "test-batch"
  }
}'

Tags are space-separated strings (not arrays) in these endpoints.

Delete Notes

curl -s http://localhost:8765 -d '{
  "action": "deleteNotes",
  "version": 6,
  "params": {"notes": [1773005026062]}
}'

Permanent and irreversible. Deletes the note and all its cards (including scheduling history).

List Available Models and Decks

# All deck names
curl -s http://localhost:8765 -d '{"action":"deckNames","version":6}'

# All model (note type) names
curl -s http://localhost:8765 -d '{"action":"modelNames","version":6}'

# Fields for a model
curl -s http://localhost:8765 -d '{
  "action": "modelFieldNames",
  "version": 6,
  "params": {"modelName": "Basic"}
}'
# → {"result": ["Front", "Back"], "error": null}

04 — Deduplication Behavior and File Import

Two Ways to Add Cards

Method Requires Best for
AnkiConnect API Anki running + addon Automation, CI/CD, agent workflows
TSV file import Just Anki Desktop Failover, sharing, manual bulk import

AnkiConnect Deduplication

When using addNote with allowDuplicate: false, AnkiConnect checks the first field within the specified scope before adding:

"options": {
    "allowDuplicate": false,
    "duplicateScope": "deck",
    "duplicateScopeOptions": {
        "deckName": "MyDeck",
        "checkChildren": true
    }
}

If a duplicate is found, the API returns:

{"result": null, "error": "cannot create note because it is a duplicate"}

This makes addNote idempotent — safe to call repeatedly with the same data.

TSV File Import (Failover Mode)

When AnkiConnect is unavailable, export cards as tab-separated values (.tsv) and import manually via Anki Desktop.

TSV Format

# Lines starting with # are ignored
# Fields separated by TAB: Front, Back, Tags (space-separated)
Wie viele Kantone hat die Schweiz?	26 Kantone	geography facts ch01
Was ist die Bundesstadt?	Bern<br>(keine offizielle Hauptstadt)	geography facts ch01
  • Fields are tab-separated (not comma, not semicolon)
  • HTML is supported in field values (<br>, <b>, etc.)
  • Tags are space-separated within the tags column
  • Lines starting with # are comments (ignored by Anki)

Import Steps

  1. File → Import... in Anki Desktop
  2. Select the .tsv file
  3. Configure:
    • Field separator: Tab
    • Allow HTML in fields: Yes
    • Existing notes: Update (default) or Ignore
    • Deck: select target deck
    • Field mapping: Field 1 → Front, Field 2 → Back, Field 3 → Tags
  4. Click Import

File Import Deduplication (Official Documentation)

Anki's built-in file import has robust deduplication. All quotes below are from the official Anki manual — Importing Text Files.

Identity Key: First Field (the "Primary Key")

"When importing text files, Anki uses the first field to determine if a note is unique."

The first field (Front) acts as a primary key — analogous to a database primary key. The composite identity is (note_type, first_field). If two notes share the same note type and first field value, Anki treats them as the same note. All deduplication logic — both in file import and AnkiConnect API — keys off this pair.

Default Behavior: Update in Place

"By default, if the file you are importing has a first field that matches one of the existing notes in your collection and that existing note is the same type as the type you're importing, the existing note's other fields will be updated based on content of the imported file."

This means re-importing a file is safe — it updates existing cards rather than creating duplicates.

Three Duplicate Handling Modes

"A drop-down box in the import screen allows you to change this behaviour, to either ignore duplicates completely, or import them as new notes instead of updating existing ones."

Mode Behavior Use when
Update existing notes (default) Matched notes get non-key fields overwritten You improved card content
Ignore duplicates Matched notes skipped entirely Safe re-import, no overwrites
Import as new notes No dedup, creates duplicates Almost never — avoid this

Scheduling Is Preserved

"If you have updating turned on and older versions of the notes you're importing are already in your collection, they will be updated in place (in their current decks) rather than being moved to the deck you have set in the import dialog. If notes are updated in place, the existing scheduling information on all their cards will be preserved."

Key takeaway: re-importing does not reset your review progress.

Match Scope

"The match scope setting controls how duplicates are identified. When note type is selected, Anki will identify a duplicate if another note with the same note type has the same first field. When set to note type and deck, a duplicate will only be flagged if the existing note also happens to be in the deck you are importing into."

Match Scope Dedup scope
Note type (default) Collection-wide — safest
Note type and deck Only within target deck — can create duplicates across decks

One Card = One Deck (No Cross-Deck Superposition)

Each card belongs to exactly one deck at any given time. A card cannot exist in multiple decks simultaneously — there is no "superposition" where the same card shares scheduling state across decks.

From Anki manual — Filtered Decks:

Cards are moved (not copied) to filtered decks, and each card retains a link to its "home deck" — the single deck it belongs to.

Consequence: if you import the same .tsv file into deck A and then into deck B:

  • Match scope = note type (default): duplicate detected → card stays in deck A, updated in place. Deck B gets nothing. Scheduling in A is preserved.
  • Match scope = note type and deck: not flagged as duplicate → creates a second independent note in deck B with separate, independent scheduling. These are now two unrelated cards that happen to look the same — not a shared card.

There is no mechanism to have one card appear in two decks with shared review state. If you want a card in a different deck, move it (changeDeck via AnkiConnect) — this transfers it fully, including scheduling history.

Summary for Programmers

It is safe to:

  • Push the same batch multiple times via AnkiConnect (allowDuplicate: false)
  • Import the same .tsv file multiple times (default "Update" mode)
  • Import overlapping card sets from different files

Anki guarantees:

  • Primary key = (note_type, first_field) — this pair is the identity
  • One card, one deck — no cross-deck scheduling superposition
  • Duplicates are detected and handled gracefully
  • Scheduling state survives updates and moves
  • No data loss on re-import

05 — Statistics and Scheduling Queries

Deck Statistics

curl -s http://localhost:8765 -d '{
  "action": "getDeckStats",
  "version": 6,
  "params": {"decks": ["MyDeck"]}
}' | python3 -m json.tool

Returns per deck:

{
    "deck_id": 1773004989383,
    "name": "MyDeck",
    "new_count": 4,
    "learn_count": 1,
    "review_count": 0,
    "total_in_deck": 8
}
Field Meaning
new_count Cards never reviewed
learn_count Cards in learning phase (seen today, not yet graduated)
review_count Cards due for review today
total_in_deck Total cards in deck

Card Scheduling State

Each card has a queue and type that determine its state:

# Get card IDs
curl -s http://localhost:8765 -d '{
  "action": "findCards",
  "version": 6,
  "params": {"query": "deck:MyDeck"}
}'

# Get scheduling details
curl -s http://localhost:8765 -d '{
  "action": "cardsInfo",
  "version": 6,
  "params": {"cards": [CARD_ID_1, CARD_ID_2]}
}'

Queue Values

queue Name Meaning
0 NEW Never seen — waiting for first review
1 LEARNING In learning steps (seen recently, not graduated)
2 REVIEW Graduated — scheduled for future review
3 RELEARNING Failed a review, back in learning steps
-1 SUSPENDED Manually suspended — won't appear in reviews
-2 BURIED (auto) Buried by sibling card limit
-3 BURIED (manual) Manually buried

Key Scheduling Fields

Field Meaning
interval Days between reviews (0 for learning cards)
due Next review — days since epoch (review) or Unix timestamp (learning)
reps Total number of reviews
lapses Times card was forgotten (answered "Again" after graduating)
factor Ease factor (2500 = default = 250%, higher = easier)

Interpreting Due Dates

from datetime import datetime

card = cards_info[0]
if card["type"] == 2:  # REVIEW card
    # 'due' is relative days — compare against collection creation date
    print(f"Review in {card['interval']} days")
elif card["type"] == 1:  # LEARNING card
    # 'due' is Unix timestamp
    due_time = datetime.fromtimestamp(card["due"])
    print(f"Due at {due_time}")
elif card["type"] == 0:  # NEW card
    # 'due' is position in new card queue
    print(f"Queue position: {card['due']}")

Example: Full Deck Snapshot

This Python snippet dumps the full scheduling state of a deck:

import json
from urllib.request import Request, urlopen

def anki(action, params=None, port=8765):
    payload = {"action": action, "version": 6}
    if params:
        payload["params"] = params
    req = Request(f"http://localhost:{port}",
                  data=json.dumps(payload).encode(),
                  headers={"Content-Type": "application/json"})
    with urlopen(req, timeout=10) as r:
        data = json.loads(r.read())
    if data.get("error"):
        raise Exception(data["error"])
    return data["result"]

deck = "MyDeck"

# Get all cards
card_ids = anki("findCards", {"query": f"deck:{deck}"})
cards = anki("cardsInfo", {"cards": card_ids})

queue_names = {0: "NEW", 1: "LEARNING", 2: "REVIEW",
               3: "RELEARNING", -1: "SUSPENDED"}

for c in cards:
    front = c["fields"]["Front"]["value"][:50]
    q = queue_names.get(c["queue"], f"?({c['queue']})")
    print(f"[{q:11s}] interval={c['interval']:3d}d  "
          f"reps={c['reps']}  lapses={c['lapses']}  {front}")

Example output:

[REVIEW     ] interval=  5d  reps=1  lapses=0  Wie viele Mitglieder hat der Bundesrat?
[LEARNING   ] interval=  0d  reps=4  lapses=0  Was ist die Bundesversammlung?
[REVIEW     ] interval=  3d  reps=1  lapses=0  du-Form von «dürfen»: «Du ___ in der Sc
[REVIEW     ] interval=  1d  reps=2  lapses=0  sein/ihr? — «Die Schweiz hat ___ Bundess

Collection-Level Statistics

# Number of cards reviewed today
curl -s http://localhost:8765 -d '{
  "action": "getNumCardsReviewedToday",
  "version": 6
}'

# Review counts by day (last 30 days)
curl -s http://localhost:8765 -d '{
  "action": "getNumCardsReviewedByDay",
  "version": 6
}'
# → {"result": [["2026-03-08", 4], ["2026-03-07", 12], ...]}

# Collection stats HTML (same as Anki's Stats screen)
curl -s http://localhost:8765 -d '{
  "action": "getCollectionStatsHTML",
  "version": 6,
  "params": {"wholeCollection": true}
}'

Useful Queries for Agents

# Cards due today across all decks
curl -s http://localhost:8765 -d '{"action":"findCards","version":6,"params":{"query":"is:due"}}'

# New cards not yet seen
curl -s http://localhost:8765 -d '{"action":"findCards","version":6,"params":{"query":"deck:MyDeck is:new"}}'

# Cards with lapses (forgotten at least once)
curl -s http://localhost:8765 -d '{"action":"findCards","version":6,"params":{"query":"deck:MyDeck prop:lapses>0"}}'

# Cards with low ease (struggling cards)
curl -s http://localhost:8765 -d '{"action":"findCards","version":6,"params":{"query":"deck:MyDeck prop:ease<2"}}'

# Cards reviewed in last 7 days
curl -s http://localhost:8765 -d '{"action":"findCards","version":6,"params":{"query":"deck:MyDeck rated:7"}}'

06 — Deck Operations (Create, List, Rename, Delete, Move Cards)

List Decks

# Names only
curl -s http://localhost:8765 -d '{"action":"deckNames","version":6}'
# → {"result": ["Default", "MyDeck", "MyDeck::SubDeck"], "error": null}

# Names with IDs
curl -s http://localhost:8765 -d '{"action":"deckNamesAndIds","version":6}'
# → {"result": {"Default": 1, "MyDeck": 1773004989383}, "error": null}

Create Deck

curl -s http://localhost:8765 -d '{
  "action": "createDeck",
  "version": 6,
  "params": {"deck": "MyDeck"}
}'
# → {"result": 1773004989383, "error": null}
  • Idempotent — safe to call multiple times, returns existing deck ID if already exists
  • Nested decks — use :: separator: "MyDeck::Chapter01::Vocab"

Delete Deck

curl -s http://localhost:8765 -d '{
  "action": "deleteDecks",
  "version": 6,
  "params": {"decks": ["MyDeck"], "cardsToo": true}
}'
  • cardsToo: true is required since Anki 2.1.28 (even for empty decks)
  • Irreversible — deletes the deck and all cards with their scheduling history

Move Cards Between Decks

# Find cards in source deck
curl -s http://localhost:8765 -d '{
  "action": "findCards",
  "version": 6,
  "params": {"query": "deck:OldDeck"}
}'
# → {"result": [123, 456, 789], "error": null}

# Move them
curl -s http://localhost:8765 -d '{
  "action": "changeDeck",
  "version": 6,
  "params": {"cards": [123, 456, 789], "deck": "NewDeck"}
}'

Scheduling state (intervals, due dates, reps, lapses) is fully preserved on move.

Rename Deck (Workaround)

AnkiConnect has no native renameDeck action. Use this 3-step pattern:

# 1. Create deck with new name
curl -s http://localhost:8765 -d '{
  "action": "createDeck", "version": 6,
  "params": {"deck": "NewName"}
}'

# 2. Move all cards from old deck to new deck
CARDS=$(curl -s http://localhost:8765 -d '{
  "action": "findCards", "version": 6,
  "params": {"query": "deck:OldName"}
}' | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['result']))")

curl -s http://localhost:8765 -d "{
  \"action\": \"changeDeck\", \"version\": 6,
  \"params\": {\"cards\": $CARDS, \"deck\": \"NewName\"}
}"

# 3. Delete the old empty deck
curl -s http://localhost:8765 -d '{
  "action": "deleteDecks", "version": 6,
  "params": {"decks": ["OldName"], "cardsToo": true}
}'

All scheduling is preserved through the move.

Deck Stats

curl -s http://localhost:8765 -d '{
  "action": "getDeckStats",
  "version": 6,
  "params": {"decks": ["MyDeck"]}
}'

Returns: new_count, learn_count, review_count, total_in_deck.

Quick Reference

Operation Action Key params
List names deckNames
List with IDs deckNamesAndIds
Create createDeck deck (idempotent, :: for nesting)
Delete deleteDecks decks[], cardsToo: true (required)
Move cards changeDeck cards[], deck (target)
Stats getDeckStats decks[]
Rename no native action create → changeDeck → deleteDecks

07 — Moving, Splitting, and Merging Decks (Scheduling Preservation Proof)

TL;DR

Yes — you can freely move cards between decks, split decks, and merge decks without losing any scheduling data (intervals, due dates, ease factors, review history, lapse counts). Scheduling is a property of the card, not the deck. Decks are organizational containers only.


Official Evidence

No single Anki doc contains an explicit statement "scheduling is stored on the card, not the deck." Instead, this is established by converging evidence from multiple official sources.

Evidence 1: "Change Deck" Has No Scheduling Side-Effects

Source: Anki Manual — Browsing

The Change Deck action is described as:

"Move currently selected cards to a different deck."

No caveats about scheduling modification. Compare with the Reset action on the same page, which explicitly documents what it changes:

"Reset card: Moves the current card to the end of the new queue. The existing review history is preserved."

If Change Deck modified scheduling, the manual would say so — just as it does for Reset. The silence is the evidence.

Evidence 2: Scheduling Survives Import Updates

Source: Anki Manual — Importing Text Files

"If you have updating turned on and older versions of the notes you're importing are already in your collection, they will be updated in place (in their current decks) rather than being moved to the deck you have set in the import dialog. If notes are updated in place, the existing scheduling information on all their cards will be preserved."

This is the most explicit official statement about scheduling preservation.

Evidence 3: Deck Options Are Not Retroactive

Source: Anki Manual — Deck Options

"Deck options are not retroactive. For example, if you change an option that controls the delay after failing a card, cards that you failed before changing this option will still have the old delay, not the new one."

"When Anki shows a card, it will check which subdeck the card is in, and use the options for that deck."

This confirms: existing interval/ease/due are baked into the card record. Deck options govern future scheduling computations, not past state. Moving a card to a new deck changes which options apply going forward but does not touch existing scheduling data.

Evidence 4: Ease and Interval Are Card-Level Fields

Source: Anki Manual — Browsing

The Browser displays per-card columns:

"(Avg.) Ease — The card's ease if it is not new"

"(Avg.) Interval — The card's interval if the card is in review or relearning"

These are card-level attributes visible in the card browser, not deck-level.

Evidence 5: Filtered Decks Confirm Card-Level Scheduling

Source: Anki Manual — Filtered Decks

"When a card is moved to a filtered deck, it retains a link to the deck from which it came. That previous deck is said to be the card's 'home deck'."

"By default, Anki will return cards to their home decks with altered scheduling, based on your performance in the filtered deck."

Filtered decks are the one case where Anki explicitly does modify scheduling on deck transition — and the manual documents this explicitly with a toggle to disable it. Regular deck moves (Change Deck, changeDeck API) have no such documentation because they don't modify scheduling.

Evidence 6: AnkiConnect changeDeck — No Scheduling Parameters

Source: AnkiConnect README

"Moves cards with the given IDs to a different deck, creating the deck if it doesn't exist yet."

{
    "action": "changeDeck",
    "version": 6,
    "params": {
        "cards": [1502098034045, 1502098034048],
        "deck": "Japanese::JLPT N3"
    }
}

No scheduling parameters in, no scheduling data out. The operation is purely a membership change.


Summary of Evidence

Claim Evidence Source
Change Deck doesn't modify scheduling No caveats in manual (contrast: Reset does document changes) browsing.html
Import preserves scheduling "existing scheduling information...will be preserved" importing/text-files.html
Deck options don't retroactively change cards "Deck options are not retroactive" deck-options.html
Ease/interval stored per card Displayed as card-level browser columns browsing.html
Filtered deck moves do alter scheduling (documented exception) "altered scheduling, based on your performance" filtered-decks.html
API changeDeck has no scheduling side-effects No scheduling params in API spec anki-connect

Practical Operations

Split a Deck into Smaller Decks

# 1. Create target decks
curl -s http://localhost:8765 -d '{
  "action": "createDeck", "version": 6,
  "params": {"deck": "German::Grammar"}
}'
curl -s http://localhost:8765 -d '{
  "action": "createDeck", "version": 6,
  "params": {"deck": "German::Vocabulary"}
}'

# 2. Find cards by tag (or content)
GRAMMAR=$(curl -s http://localhost:8765 -d '{
  "action": "findCards", "version": 6,
  "params": {"query": "deck:German tag:grammar"}
}' | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['result']))")

VOCAB=$(curl -s http://localhost:8765 -d '{
  "action": "findCards", "version": 6,
  "params": {"query": "deck:German tag:vocab"}
}' | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['result']))")

# 3. Move cards to their new homes
curl -s http://localhost:8765 -d "{
  \"action\": \"changeDeck\", \"version\": 6,
  \"params\": {\"cards\": $GRAMMAR, \"deck\": \"German::Grammar\"}
}"
curl -s http://localhost:8765 -d "{
  \"action\": \"changeDeck\", \"version\": 6,
  \"params\": {\"cards\": $VOCAB, \"deck\": \"German::Vocabulary\"}
}"

# 4. (Optional) Delete original deck if empty
curl -s http://localhost:8765 -d '{
  "action": "deleteDecks", "version": 6,
  "params": {"decks": ["German"], "cardsToo": true}
}'

All scheduling (intervals, due dates, ease, reps, lapses) is preserved through the move.

Merge Multiple Decks into One

# 1. Find all cards in source decks
DECK_A=$(curl -s http://localhost:8765 -d '{
  "action": "findCards", "version": 6,
  "params": {"query": "deck:DeckA"}
}' | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['result']))")

DECK_B=$(curl -s http://localhost:8765 -d '{
  "action": "findCards", "version": 6,
  "params": {"query": "deck:DeckB"}
}' | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['result']))")

# 2. Move all to target deck
curl -s http://localhost:8765 -d "{
  \"action\": \"changeDeck\", \"version\": 6,
  \"params\": {\"cards\": $DECK_A, \"deck\": \"MergedDeck\"}
}"
curl -s http://localhost:8765 -d "{
  \"action\": \"changeDeck\", \"version\": 6,
  \"params\": {\"cards\": $DECK_B, \"deck\": \"MergedDeck\"}
}"

# 3. Delete empty source decks
curl -s http://localhost:8765 -d '{
  "action": "deleteDecks", "version": 6,
  "params": {"decks": ["DeckA", "DeckB"], "cardsToo": true}
}'

Verify Scheduling Was Preserved

# Before moving, snapshot card state:
curl -s http://localhost:8765 -d '{
  "action": "cardsInfo", "version": 6,
  "params": {"cards": [CARD_ID]}
}' | python3 -c "
import json,sys
c = json.load(sys.stdin)['result'][0]
print(f'interval={c[\"interval\"]} due={c[\"due\"]} reps={c[\"reps\"]} '
      f'lapses={c[\"lapses\"]} factor={c[\"factor\"]} deck={c[\"deckName\"]}')
"

# After moving, run the same query — all values except deckName should be identical

What Changes, What Doesn't

Property After move
deckName Changed (new deck)
interval Preserved
due Preserved
reps Preserved
lapses Preserved
factor (ease) Preserved
queue / type Preserved
Review history Preserved
Deck options applied on next review New deck's options
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment