Back to writing

London, Ontario

12 min read

How to use the Lichess puzzle database

Download the Lichess puzzle CSV, stream it in TypeScript, pull out mate-in-2 puzzles by theme and rating, and check every solution with chess.js.

Lichess publishes its puzzles, 6,100,952 of them when I wrote this, as a single CSV file that you can use for anything. You download it, stream it through zstd and read it a line at a time. The one catch is that the first move in each row is your opponent’s, so you play it before you show the puzzle.

I use this file for Spot the Checkmate, a YouTube channel that turns mate-in-N puzzles into Shorts. Here’s the whole path in TypeScript, from the download to a JSON file of checked mate-in-2 puzzles that an app can use.

Where do I get the file?

From the puzzles section of database.lichess.org. It’s one file, lichess_db_puzzle.csv.zst: a CSV compressed with Zstandard, about 304 MB. When I checked, the page said it was last updated on 10 September 2026.

curl -O https://database.lichess.org/lichess_db_puzzle.csv.zst

You don’t have to unpack it. zstd -dc decompresses to standard output, so you can look at the first lines straight away:

zstd -dc lichess_db_puzzle.csv.zst | head -3
PuzzleId,FEN,Moves,Rating,RatingDeviation,Popularity,NbPlays,Themes,GameUrl,OpeningTags,DailyDate
00008,r6k/pp2r2p/4Rp1Q/3p4/8/1N1P2R1/PqP2bPP/7K b - - 0 24,f2g3 e6e7 b2b1 b3c1 b1c1 h6c1,1797,76,95,10183,crushing hangingPiece long middlegame,https://lichess.org/787zsVup/black#48,,
0000D,5rk1/1p3ppp/pq3b2/8/8/1P1Q1N2/P4PPP/3R2K1 w - - 2 27,d3d6 f8d8 d6d8 f6d8,1468,75,96,37410,advantage endgame short,https://lichess.org/F8M8OS71#53,,

If you don’t have zstd, it’s in Homebrew and most Linux package managers.

What’s in the file?

One puzzle per line, in eleven columns. Here’s one row, a mate in 2, laid out a field per line:

PuzzleId         00sHx
FEN              q3k1nr/1pp1nQpp/3p4/1P2p3/4P3/B1PP1b2/B5PP/5K2 b k - 0 17
Moves            e8d7 a2e6 d7d8 f7f8
Rating           1525
RatingDeviation  75
Popularity       87
NbPlays          162
Themes           mate mateIn2 middlegame short
GameUrl          https://lichess.org/yyznGmXs/black#34
OpeningTags      Italian_Game Italian_Game_Classical_Variation
DailyDate
ColumnWhat it holds
PuzzleIdThe puzzle’s ID. The puzzle itself is at lichess.org/training/ followed by the ID.
FENThe board before the opponent’s move. More on that below.
MovesThe opponent’s move, then the solution, in UCI notation: e8d7 is a move from e8 to d7.
RatingHow hard it is. Every attempt counts as a Glicko-2 rated game between the solver and the puzzle.
RatingDeviationHow settled that rating is. Lower means more certain.
PopularityPlayers’ votes, from 100 (best) to −100 (worst).
NbPlaysHow many times it’s been played.
ThemesTags separated by spaces, such as mateIn2, fork or endgame.
GameUrlThe Lichess game the puzzle comes from.
OpeningTagsThe opening, but only for puzzles that start before move 20.
DailyDateWhen it was the Lichess daily puzzle, as a Unix timestamp in milliseconds.

The notes on the download page define most of these. DailyDate is empty for most puzzles: only 38 of the first 100,000 in the file have one.

How do I read it without loading it all?

Stream it. zstd -dc decompresses to standard output, a pipe hands that to Node, and Node reads it a line at a time, so the whole file is never in memory.

The scripts here are TypeScript. Node runs a .ts file directly from version 22.18, or 23.6 on the 23 line (node count.ts); on older versions, npx tsx count.ts does the same. They use import, so your package.json needs "type": "module".

// puzzles.ts
import { createInterface } from "node:readline";

// One CSV row in, one puzzle out.
export function parse(line: string) {
  const c = line.split(",");
  if (c.length !== 11) throw new Error(`Expected 11 fields: ${line}`);

  return {
    id: c[0],
    fen: c[1],
    moves: c[2].split(" "),
    rating: Number(c[3]),
    ratingDeviation: Number(c[4]),
    popularity: Number(c[5]),
    plays: Number(c[6]),
    themes: c[7].split(" "),
    gameUrl: c[8],
    openingTags: c[9] ? c[9].split(" ") : [],
    dailyDate: c[10] ? new Date(Number(c[10])) : null,
  };
}

export type Puzzle = ReturnType<typeof parse>;

// Reads stdin a line at a time, so memory use stays flat.
export async function* readPuzzles() {
  for await (const line of createInterface({ input: process.stdin })) {
    if (!line.startsWith("PuzzleId")) yield parse(line); // skip the header
  }
}

A plain split on commas is enough: in the first 100,000 rows, every line splits into exactly eleven fields. The length check makes the script fail loudly if a row ever doesn’t.

Here’s the reader in use, counting the mate tags:

// count.ts
import { readPuzzles } from "./puzzles.ts";

const counts: Record<string, number> = {};

for await (const puzzle of readPuzzles()) {
  for (const theme of puzzle.themes) {
    if (theme.startsWith("mateIn")) counts[theme] = (counts[theme] ?? 0) + 1;
  }
}

console.log(counts);

While you’re working on a script, run it on a slice. head -n 100001 keeps the header and the first 100,000 puzzles, and once it has them, the decompression stops:

zstd -dc lichess_db_puzzle.csv.zst | head -n 100001 | node count.ts
{
  mateIn2: 13228,
  mateIn1: 14821,
  mateIn3: 3217,
  mateIn4: 488,
  mateIn5: 80
}

From here on, every count comes from that slice: the first 100,000 puzzles in the September 2026 file. To run on the whole file, leave out head.

Why is the first move the opponent’s?

Because the FEN isn’t the puzzle. It’s the position one move earlier, before your opponent plays. The notes spell it out: play the first move on that FEN and you have the position to show, and the solution starts with the second move. After that, the moves alternate, yours and then theirs, ending on yours.

Look at 00sHx again. Its FEN says b, Black to move, but on Lichess the puzzle is “White to play”. Its four moves:

  1. e8d7: Black’s king steps out of check to d7. That’s the opponent’s move, and the puzzle starts after it.
  2. a2e6: Be6+, your first move.
  3. d7d8: Kd8, Black’s only legal reply.
  4. f7f8: Qf8#.

In code, that means playing the first move before anything else. chess.js handles the rules (npm install chess.js; I used 1.4.0), and a UCI move only needs splitting into its squares:

// play.ts
import { Chess } from "chess.js";
import type { Puzzle } from "./puzzles.ts";

// A UCI move is the from square, the to square and, for a promotion, the piece.
const uci = (move: string) => ({
  from: move.slice(0, 2),
  to: move.slice(2, 4),
  promotion: move[4],
});

// Plays the opponent's move, then the solution. Null if a move is illegal.
export function play(puzzle: Puzzle) {
  try {
    const chess = new Chess(puzzle.fen);
    const [opponentMove, ...solution] = puzzle.moves;

    chess.move(uci(opponentMove));
    const fen = chess.fen(); // the position to show the player

    const san = solution.map((move) => chess.move(uci(move)).san);
    const checkmate = chess.isCheckmate();
    return { fen, lastMove: opponentMove, solution: san, checkmate };
  } catch {
    return null; // chess.js throws on an illegal move or a bad FEN
  }
}

On 00sHx, it returns:

{
  fen: 'q5nr/1ppknQpp/3p4/1P2p3/4P3/B1PP1b2/B5PP/5K2 w - - 1 18',
  lastMove: 'e8d7',
  solution: [ 'Be6+', 'Kd8', 'Qf8#' ],
  checkmate: true
}

The new FEN says w. The side to move is now the player’s side, so that’s the colour to put at the bottom of the board. Keep lastMove too, so you can highlight the move the opponent just made.

If you forget the first move, chess.js stops you on the spot. On the original FEN it’s Black’s turn, so a2e6 is a white bishop moving out of turn, and move() throws Invalid move: {"from":"a2","to":"e6"}. Inside play(), any error like that is caught and becomes null.

How do I find mate-in-2 puzzles?

Filter on the columns. Themes has a tag for each mate length, mateIn1 to mateIn5, where mateIn5 means five moves or more; Lichess’s theme list explains every tag. Rating sets the level. Three more columns help you keep the good ones:

  • Popularity: players liked it. It comes from up and down votes, weighted by things like whether the voter solved the puzzle.
  • NbPlays: plenty of people have tried it.
  • RatingDeviation: its rating has settled. With few plays, a rating can still be moving.

The rest of the code goes in one script, mate-in-2.ts. It starts with the filter:

// mate-in-2.ts
import { writeFile } from "node:fs/promises";
import { readPuzzles, type Puzzle } from "./puzzles.ts";
import { play } from "./play.ts";

const wanted = (p: Puzzle) =>
  p.themes.includes("mateIn2") &&
  p.rating >= 1200 &&
  p.rating <= 1800 &&
  p.popularity >= 90 &&
  p.plays >= 1000 &&
  p.ratingDeviation <= 80;

Each condition cuts the list down. In the first 100,000 puzzles:

ConditionPuzzles left
Tagged mateIn213,228
Rated 1200 to 18004,051
Popularity of 90 or more2,216
At least 1,000 plays1,651
Rating deviation of 80 or less1,500

The thresholds are a matter of taste. These keep about one mate-in-2 puzzle in nine.

How do I check the solutions?

Replay them, which play() already does. chess.js throws on an illegal move, so a bad row comes back as null, and after the last move it can tell you whether the position is checkmate. The length is worth checking too: after the opponent’s move, a mate in N takes 2N − 1 moves, so a mate in 2 is three: yours, theirs, then mate.

I ran play() on all of the first 100,000 puzzles. chess.js accepted every move, including 594 castles, 3,726 promotions and 198 en passant captures, and all 31,834 puzzles tagged as mates ended in checkmate at the length their tag gives. So the check mostly guards against your own mistakes, like forgetting the opponent’s move. Two things to know, though:

  • mateIn5 covers five moves or more. In the first 100,000 puzzles, those go up to mate in 9, so only check the length for mateIn1 to mateIn4.
  • The tags can miss mates. In the first 100,000 puzzles, 162 end in checkmate with no mate tag at all, usually just a phase such as endgame.

Finding those means replaying every puzzle, and chess.js is the slow part: on my machine it took 35.6 seconds for the 100,000 puzzles, where reading and parsing them took 0.1. So filter on the columns first, and replay only what’s left.

How do I save them for an app?

The rest of mate-in-2.ts reads every puzzle, keeps the ones that pass the filter and the check, and writes them out as JSON:

// mate-in-2.ts, continued
const kept = [];

for await (const puzzle of readPuzzles()) {
  if (!wanted(puzzle)) continue;

  // After the opponent's move, a mate in 2 is three moves: yours, theirs, mate.
  const line = play(puzzle);
  if (!line?.checkmate || line.solution.length !== 3) {
    console.warn(`Skipped ${puzzle.id}`);
    continue;
  }

  kept.push({
    id: puzzle.id,
    fen: line.fen,
    lastMove: line.lastMove,
    solution: line.solution,
    rating: puzzle.rating,
    url: `https://lichess.org/training/${puzzle.id}`,
  });
}

await writeFile("mate-in-2.json", JSON.stringify(kept, null, 2));
console.log(`Saved ${kept.length} puzzles`);

Run it on the slice:

zstd -dc lichess_db_puzzle.csv.zst | head -n 100001 | node mate-in-2.ts

It prints Saved 1500 puzzles and writes a 381 KB file. Here’s the first entry:

{
  "id": "005Bm",
  "fen": "4rk2/p4q2/1p3Q1b/8/1p5N/2P1p3/P3P3/2K5 w - - 1 44",
  "lastMove": "c7f7",
  "solution": [
    "Ng6+",
    "Kg8",
    "Qh8#"
  ],
  "rating": 1291,
  "url": "https://lichess.org/training/005Bm"
}

Each entry has what a puzzle screen needs. fen is the position after the opponent’s move, so it’s the player’s turn. lastMove is the opponent’s move in UCI, for highlighting. solution is in SAN, for display. To check an answer, play the player’s move with chess.js and accept it if its SAN matches the next move in the list or if it gives checkmate, because the last move of a mate can have more than one answer. url links back to the puzzle on Lichess.

FAQ

Can I use the puzzles in a commercial app? Yes. Lichess releases its database exports under the CC0 licence and says on the download page that you can use them for anything, commercial use included, without asking.

Do I have to credit Lichess? No, CC0 doesn’t require it. The CC0 deed does ask that you don’t imply Lichess endorses what you’ve made. A link back to each puzzle is still a good idea, and the url field makes it easy.

How often is the file updated? The download page doesn’t give a schedule. It shows when the file was last updated (10 September 2026 when I checked), so compare that date before you download it again. Ratings and play counts keep changing in between: the sample on the page shows 00sHx rated 1760 after 72 plays, and the September file has it at 1525 after 162.

What do the themes mean? lichess.org/training/themes lists every tag with a line of explanation, and the download page links the file they come from. The ones you’re most likely to filter on:

  • mateIn1 to mateIn5: mate in that many moves, where mateIn5 means five or more.
  • oneMove, short, long, veryLong: one move to win, two, three, or four or more.
  • advantage, crushing, equality: winning a decisive advantage, winning a crushing one, or saving a draw from a losing position.
  • opening, middlegame, endgame: the phase of the game.
  • master, masterVsMaster, superGM: from games played by titled players, between two titled players, or by the world’s best.

Can a puzzle have more than one right answer? Yes, at the mating move. The notes say every move you play in a solution is the only good one, with one exception: when there’s a mate in one, there can be several, and any move that checkmates should win. In the first 100,000 puzzles, 472 mate-in-1 puzzles have more than one mating move, and so does the last move of 844 mate-in-2 puzzles. So accept any move that gives checkmate, not just the one in the file.

Waqar