Lichess Games Export: Moves, Openings, Ratings, Tournaments
Pricing
from $0.70 / 1,000 chess data rows
Lichess Games Export: Moves, Openings, Ratings, Tournaments
Export a chess player's games from Lichess as rows: result, ratings, time control, opening, move list, clocks and accuracy, filtered by speed, rated flag, colour, opponent and date. Also player profiles with every variant rating, rating history for charts, arena standings and top-player boards.
Pricing
from $0.70 / 1,000 chess data rows
Rating
0.0
(0)
Developer
Samat Makatov
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
Export a chess account's games from lichess.org as flat rows: opponent and both ratings, colour, result, time control, opening with its ECO code, the full move list, the clock after every move and the accuracy figures of the server analysis. The same actor also answers the three questions that sit next to a games export — how strong is this account (profile and every variant rating), how did its rating move day by day, and what was the final table of a tournament — plus the current top list of any speed or variant. No API key, no login, no proxy, no browser: the open Lichess API, read one request at a time. Made by Yadroo.
The games themselves are released by Lichess into the public domain (CC0). Rows carry public chess data only — account name, chess title, country code and FIDE rating where the player published them, ratings, counts, moves, standings. The free-text part of a profile (real name, biography, links) is deliberately never written to a row, and the source has no contact field to read.
Use cases
- Feed an engine or a model —
mode: "games"withincludeMoves,includeClocksandincludeAccuracy: every game arrives as one row with the moves in algebraic notation, the remaining time after each move and the server's accuracy, average centipawn loss and blunder counts. That is the step most review tools start with, without anyone downloading a file by hand. - Coaching and opening work —
color: "black",perfTypes: ["rapid"],rated: "rated": one colour, one speed, newest first.openingNameandopeningEcomake a repertoire table a group-by away. - Watch an account on a schedule —
sinceHours: 24withonlyNew: true: every run holds the games played since the last look and nothing else, so an empty run means the player did not play. No date editing between runs. - Export a whole career in instalments —
sortDescending: falsewithonlyNew: trueand amaxItemsper run: each run continues forwards from the newest game the previous runs wrote. - A head-to-head record —
opponent: "someUsername": only the games these accounts played against each other, in either colour. If they never met, the run says so instead of quietly returning everybody else's games. - A ratings table for a club page or a seeding list —
mode: "player"with a list of usernames: rating and games per speed, win rate, title, country, FIDE rating and the join date, one row per account. - A rating curve for a chart or a report —
mode: "ratingHistory": one row per day on which the rating moved, with the change against the previous point already computed. Nothing left to post-process. Read the note in Limits first: the source publishes this curve for part of the accounts only. - A tournament's final table —
mode: "tournament"with the id from the URL: rank, account, title, score, rating and the tournament performance, in rank order, with the event name, clock and player count on every row. - Discover strong active accounts —
mode: "leaderboard": the current top of any speed or variant, which doubles as a list of usernames to feed back intogamesmode.
Input
Each mode needs one thing and nothing else: usernames for games, player and ratingHistory, tournamentIds for
tournament, and nothing at all for leaderboard. The examples in the table below sit in the schema as prefills, so a
run started from the Apify Console already has usernames and tournamentIds filled in and works with one click. A
call from the API, a client library or a task sends its own input: an empty body is answered with an error naming the
field it needs, because a default account or tournament would silently turn every run into somebody else's query.
| Field | Type | Default | Allowed values / notes |
|---|---|---|---|
mode | string | games | games, player, ratingHistory, tournament, leaderboard — see Modes |
usernames | string[] | — | Accounts as they appear after the @ in a profile URL. Prefilled ["DrNykterstein", "cutemouse83"]. Used by games, player, ratingHistory. A pasted https://lichess.org/@/name is accepted |
perfTypes | string[] | — | Multi-select of speeds and variants. Empty = everything. Sent to the source in games mode |
rated | string | any | any, rated, casual |
color | string | any | any, white, black — the colour the named account played |
opponent | string | — | A second username: keep only games against that one account, either colour |
sinceHours | number | — | 1–87600. Rolling window in UTC hours, counted from the moment the run starts and compared with the moment a game started. Takes precedence over dateFrom |
dateFrom | string | — | 2026-01-01 or 2026-01-01T00:00:00Z. Fixed lower bound, compared with the moment a game started (measured: a game begun one minute before the bound is left out even if it ended after it) |
dateTo | string | — | Same formats. Fixed upper bound, exclusive, also by the start of the game |
analysedOnly | boolean | false | Keep only games with a full computer analysis — the ones that carry accuracy and ACPL |
onlyNew | boolean | false | Write only games no earlier run of the same actor or task has written. See Scheduling |
sortDescending | boolean | true | Newest games first. Off walks a career forwards (with onlyNew, each run continues where the last one stopped); also flips the rating-history order |
includeMoves | boolean | true | The move list in algebraic notation plus movesCount |
includeOpening | boolean | true | openingEco, openingName, openingPly |
includeClocks | boolean | false | The remaining time of both sides after every move, in centiseconds. Makes a row several kB |
includeEvals | boolean | false | The per-move engine evaluation of the server analysis, where one exists |
includeAccuracy | boolean | true | Accuracy, average centipawn loss and the inaccuracy/mistake/blunder counts of both sides |
sinceDays | number | — | 1–7300. ratingHistory only: keep points of the last N days. Empty = the whole history |
tournamentIds | string[] | — | The eight characters from a tournament URL. Prefilled ["ayELljKv"]. A pasted full URL is accepted |
tournamentKind | string | arena | arena or swiss — see Arena and swiss |
leaderboardPerf | string | blitz | One of the 13 top-list speeds and variants |
topCount | number | 20 | 1–200. Length of the top list |
maxItems | number | 50 | 1–2000. Hard cap on the rows written, and therefore on what the run costs. In games mode it is split over usernames. Your maximum cost per run is a second cap — see Limits |
fields | string[] | all | Keep only these columns, in the order listed. The mode's key columns stay in every row even when not named: gameId + player (games), username + found (player), username + perfType (rating history), tournamentId + found (tournament), perfType + rank (top list); a found: false row also keeps found and notFoundReason. Letter case does not matter, and the words of a name may be joined or separated by spaces, _ or - (GameID, game_id, Opening Name); an unknown name is reported in the status message with the closest column, and a list with no valid name at all stops the run before any request |
The fields with a fixed list of values (mode, perfTypes, rated, color, tournamentKind, leaderboardPerf) are
checked by Apify against the schema before a run even starts, so a misspelled value (blizt, koth) is rejected there
with the allowed values - pick them from the lists in Reference. What the actor itself cleans up is the
free text: a pasted https://lichess.org/@/name or lichess.org/tournament/<id> is reduced to the id, casing and
duplicate names do not matter, a column name in fields is matched without regard to case or to how its words are
separated (opening_name and Opening Name mean openingName), and an unknown one is named in the status message. A
search is never widened behind your back: an input that cannot be read fails with a message that says what to change.
Reference
Modes
One mode per run, one row shape per mode, one dataset view per mode. The other views stay empty.
| Mode | One row is | Reads these inputs | Source endpoint |
|---|---|---|---|
games | one finished game of one account | usernames, all game filters, all include* flags | games export of an account |
player | one account with its ratings and counts | usernames | account profile |
ratingHistory | one account, one speed, one day | usernames, perfTypes, sinceDays, sortDescending | rating history of an account — the source publishes it for part of the accounts only, see Limits |
tournament | one player in a standing | tournamentIds, tournamentKind | tournament metadata + standing |
leaderboard | one place in a top list | leaderboardPerf, topCount | top list of one speed or variant |
Inputs that belong to another mode are ignored and the status message says so — they are never applied silently.
Speeds and variants
perfTypes accepts these keys. The first six are time classes of standard chess; the next eight are variants with
their own rating; puzzle is not a game type at all and exists only as a rating curve.
| Key | Meaning |
|---|---|
ultraBullet | 30 seconds or less for the whole game |
bullet | under 3 minutes |
blitz | 3 to 8 minutes |
rapid | 8 to 25 minutes |
classical | over 25 minutes |
correspondence | days per move |
chess960 | randomised starting position |
crazyhouse | captured pieces can be dropped |
antichess | capturing is compulsory, losing everything wins |
atomic | a capture explodes the neighbouring squares |
horde | pawns against a normal army |
kingOfTheHill | the centre squares win the game |
racingKings | first king to the eighth rank |
threeCheck | three checks win the game |
puzzle | the puzzle rating — ratingHistory mode only |
In games mode several values mean OR and are sent to the source, so games outside the filter are never transferred
and never charged. puzzle in games mode is dropped with a warning instead of being sent.
Top-list speeds and variants
leaderboardPerf takes the same keys except correspondence and puzzle, which have no public top list — 13
values, from ultraBullet to threeCheck. Each list is 200 accounts long at most.
Arena and swiss
The two tournament formats live under different URLs and an id of one is not an id of the other.
| Format | URL to read the id from | tournamentKind | Rows carry |
|---|---|---|---|
| Arena — timed, pairs continuously | lichess.org/tournament/ayELljKv → ayELljKv | arena | score, the tournament performance, perfType, and the streak sheet when the standing is read page by page |
| Swiss — fixed rounds, used by clubs and teams | lichess.org/swiss/6iJdh9Ds → 6iJdh9Ds | swiss | points and tieBreak, mapped into score as well; perfType stays empty because the swiss endpoint does not publish one |
Finished tournaments stay readable, so an id keeps working long after the event. An id that does not exist in the
chosen format yields one row with found: false and a reason that points at the other format. A tournament that exists
but has not started yet yields one row with the event's metadata, an empty rank and username, and the reason in
notFoundReason.
Result and status values
result is always from the named account's point of view: win, loss, draw, or aborted for a game that never
really started. status is the source's own word for how the game ended and is more precise: mate, resign,
outoftime, stalemate, draw, timeout, aborted, noStart, cheat, variantEnd.
timeControl is written the way chess players read it: minutes plus increment in seconds. 3+0 is three minutes and
no increment, 0.25+0 is an ultrabullet game of 15 seconds, and a correspondence game reads 2 days/move.
clockInitial (seconds) and clockIncrement carry the same numbers unrounded.
Scheduling
onlyNew remembers the game ids a run wrote in a named key-value store of your account (lichess-seen-games-<taskId>,
or lichess-seen-games for runs started by hand) — one store per task, so two schedules watching two accounts do not
blind each other. Combine it with sinceHours and a small maxItems: each run then costs a handful of rows and returns
only what is new. Only games that were really written and charged are remembered: a run cut short by your spending limit
or by the timeout leaves the rest for the next run instead of marking it as seen.
With sortDescending: false, onlyNew walks a career forwards: the same store keeps, per account and filter set, the
start time of the newest game written so far, and the next run starts its stream there. Without onlyNew, the oldest
games are simply returned again on every run.
Examples
The newest rated fast games of one account
{ "mode": "games", "usernames": ["DrNykterstein"], "perfTypes": ["bullet", "blitz"], "rated": "rated", "maxItems": 20 }
Games with moves, clocks and accuracy, ready for an engine or a model
{ "mode": "games", "usernames": ["cutemouse83"], "perfTypes": ["blitz", "rapid"], "rated": "rated","includeMoves": true, "includeClocks": true, "includeAccuracy": true, "maxItems": 15 }
Everything played in the last two weeks, for a schedule
{ "mode": "games", "usernames": ["cutemouse83"], "sinceHours": 336, "onlyNew": true, "includeMoves": false, "maxItems": 20 }
One colour of one season, for opening work
{ "mode": "games", "usernames": ["thibault"], "color": "white", "perfTypes": ["rapid", "classical"],"dateFrom": "2026-01-01", "dateTo": "2026-07-01", "sortDescending": false, "maxItems": 100 }
A ratings table of several accounts
{ "mode": "player", "usernames": ["DrNykterstein", "cutemouse83", "thibault"], "maxItems": 10 }
Five years of blitz, rapid and puzzle ratings of three accounts, for one chart
{ "mode": "ratingHistory", "usernames": ["thibault", "penguingim1", "nihalsarin2004"],"perfTypes": ["blitz", "rapid", "puzzle"], "sinceDays": 1825, "maxItems": 90 }
The final table of an arena tournament
{ "mode": "tournament", "tournamentKind": "arena", "tournamentIds": ["ayELljKv"], "maxItems": 30 }
The current blitz top 20
{ "mode": "leaderboard", "leaderboardPerf": "blitz", "topCount": 20, "maxItems": 20 }
Output
One row per game, account, rating point, standing place or top-list place. Every row carries url and fetchedAt
(UTC, ISO-8601); the lookup modes also carry found and notFoundReason. Every timestamp the source publishes as
epoch milliseconds is written as a …Z string.
A games row
Real row from cloud run W0CoC50MMEb0o3ZBi (move list shortened with …):
{"gameId": "kAdOQKeh","player": "DrNykterstein","found": true,"color": "black","opponent": "respects_55","opponentRating": 2644,"opponentRatingDiff": -5,"opponentTitle": null,"playerRating": 3145,"playerRatingDiff": 8,"playerTitle": "GM","result": "win","winner": "black","status": "resign","rated": true,"variant": "standard","perfType": "blitz","speed": "blitz","timeControl": "3+0","clockInitial": 180,"clockIncrement": 0,"daysPerTurn": null,"openingEco": "B02","openingName": "Alekhine Defense: Sämisch Attack","openingPly": 5,"movesCount": 136,"moves": "e4 Nf6 e5 Nd5 Nc3 Nxc3 dxc3 d6 Nf3 Nc6 Bb5 a6 Bxc6+ bxc6 O-O f6 exf6 exf6 Nd4 Qd7 Qh5+ g6 …","clocks": null,"evals": null,"analysed": true,"playerAccuracy": 93,"opponentAccuracy": 89,"playerAcpl": 18,"opponentAcpl": 24,"playerInaccuracies": 2,"playerMistakes": 3,"playerBlunders": 1,"opponentInaccuracies": 3,"opponentMistakes": 3,"opponentBlunders": 2,"tournamentId": "mzOPeKWa","swissId": null,"createdAt": "2026-04-08T19:39:03.033Z","lastMoveAt": "2026-04-08T19:45:13.708Z","durationSecs": 371,"url": "https://lichess.org/kAdOQKeh","fetchedAt": "2026-09-30T22:22:22.545Z","notFoundReason": null}
| Field | Type | Meaning |
|---|---|---|
gameId | string | Id of the game; url is lichess.org/<gameId> |
player | string | The account this row is written for, spelled as the source spells it |
color | string | white or black — the colour that account had |
opponent | string | The other account, or Stockfish level N for a game against the engine, or Anonymous |
opponentRating, playerRating | number | Ratings at the start of the game |
opponentRatingDiff, playerRatingDiff | number | Rating change from this game; empty for casual games |
opponentTitle, playerTitle | string | Chess title (GM, IM, FM, LM, …) where the account has one |
result | string | win, loss, draw, aborted — from player's point of view |
winner | string | white, black, or empty for a draw |
status | string | How the game ended; see Result and status values |
rated | boolean | Rated games moved the rating |
variant | string | standard or the variant key |
perfType | string | The rating pool the game counted for — the speed for standard chess, the variant otherwise |
speed | string | The time class, also for variant games |
timeControl | string | Minutes + increment, e.g. 3+0; 2 days/move for correspondence |
clockInitial, clockIncrement | number | Seconds |
daysPerTurn | number | Correspondence games only |
openingEco, openingName, openingPly | string, string, number | The opening as the source classifies it. Empty for very short games and some variants |
movesCount | number | Half-moves; empty when includeMoves is off |
moves | string | Space-separated standard algebraic notation |
clocks | number[] | Remaining time after each move, centiseconds, in move order. Only with includeClocks |
evals | object[] | Per move: eval (centipawns), mate, best, judgment. Only with includeEvals, and only for analysed games |
analysed | boolean | A full computer analysis exists — the flag to filter on before trusting the accuracy columns |
playerAccuracy, opponentAccuracy | number | Accuracy percentage from the server analysis |
playerAcpl, opponentAcpl | number | Average centipawn loss |
playerInaccuracies/Mistakes/Blunders, opponent… | number | Counts from the analysis |
tournamentId, swissId | string | Set when the game was played in an arena or a swiss |
createdAt, lastMoveAt | string | Start and last move, UTC |
durationSecs | number | Wall-clock length of the game |
A player row
Real row from cloud run MFYEkGauQTaOEeRGc:
{"username": "DrNykterstein","userId": "drnykterstein","found": true,"playerTitle": "GM","patron": true,"countryCode": null,"fideRating": null,"ratingUltraBullet": 2406,"gamesUltraBullet": 92,"ratingBullet": 3243,"gamesBullet": 9583,"ratingBlitz": 3153,"gamesBlitz": 606,"ratingRapid": null,"gamesRapid": 0,"ratingClassical": null,"gamesClassical": 0,"ratingCorrespondence": null,"gamesCorrespondence": 0,"ratingPuzzle": null,"puzzleAttempts": null,"variantRatings": { "chess960": { "rating": 2541, "games": 129 }, "atomic": { "rating": 2160, "games": 25 } },"bestPerfType": "bullet","bestRating": 3243,"gamesAll": 10450,"gamesRated": 10435,"wins": 7207,"losses": 2409,"draws": 834,"winRate": 69,"playTimeHours": 324.9,"createdAt": "2018-12-06T12:44:50.814Z","seenAt": "2026-08-28T10:08:45.437Z","online": null,"streaming": false,"closed": false,"url": "https://lichess.org/@/DrNykterstein","fetchedAt": "2026-09-30T22:22:27.724Z","notFoundReason": null}
| Field | Type | Meaning |
|---|---|---|
username, userId | string | The account as spelled, and its lower-case id |
playerTitle, patron | string, boolean | Chess title; whether the account supports the site |
countryCode, fideRating | string, number | Only where the player published them on the profile |
rating<Speed> | number | Rating for UltraBullet, Bullet, Blitz, Rapid, Classical, Correspondence. Empty when the account never played that speed — the source reports a provisional starting value there, which is not a rating |
games<Speed> | number | Games in that speed; 0 is the honest answer for "never played" |
ratingPuzzle, puzzleAttempts | number | The puzzle rating is a different scale and is kept out of bestRating |
variantRatings | object | { variantKey: { rating, games } } for the variants the account has actually played; empty otherwise |
bestPerfType, bestRating | string, number | The strongest rating among the speeds and variants played |
gamesAll, gamesRated | number | Total and rated game counts |
wins, losses, draws, winRate | number | Counts and wins as a percentage of decided games, one decimal |
playTimeHours | number | Total time spent playing |
createdAt, seenAt | string | Join date and last time the account was seen, UTC |
online, streaming, closed | boolean | Live flags; closed is set for a disabled account |
A rating-history row
Real row from cloud run OabdaaIOJTXNIVYUa:
{"username": "thibault","found": true,"perfType": "blitz","perfName": "Blitz","date": "2026-09-30","rating": 1742,"ratingChange": 4,"url": "https://lichess.org/@/thibault","fetchedAt": "2026-09-30T23:03:47.460Z","notFoundReason": null}
| Field | Type | Meaning |
|---|---|---|
perfType, perfName | string | The curve as a key (kingOfTheHill) and as the source names it (King of the Hill) |
date | string | YYYY-MM-DD in UTC. The source stores the month zero-based; that is normalised here |
rating | number | Rating at the end of that day |
ratingChange | number | Difference to the previous point of the same curve. Computed over the whole history, so the first row of a window is a real change, not empty |
The source records one point per day on which the rating moved, so gaps are days without rated games, not missing data.
A tournament row
Real row from cloud run ePCEJUA5Q5iS8SCj8:
{"tournamentId": "ayELljKv","tournamentKind": "arena","tournamentName": "Hourly Bullet Arena","found": true,"nbPlayers": 320,"perfType": "bullet","variant": "standard","rated": true,"timeControl": "1+0","clockInitial": 60,"clockIncrement": 0,"startsAt": "2026-09-30T21:00:15.000Z","durationMinutes": 27,"createdBy": "lichess","rank": 1,"username": "HalThaPal","playerTitle": null,"score": 36,"points": null,"tieBreak": null,"performance": 2205,"rating": 2437,"sheetScores": null,"fire": null,"absent": null,"url": "https://lichess.org/tournament/ayELljKv","fetchedAt": "2026-09-30T22:22:31.260Z","notFoundReason": null}
| Field | Type | Meaning |
|---|---|---|
tournamentId, tournamentKind, tournamentName | string | The event, repeated on every row so the dataset reads on its own |
nbPlayers, perfType, variant, rated | number, string, string, boolean | Event properties |
timeControl, clockInitial, clockIncrement | string, number, number | The clock of the event |
startsAt, durationMinutes, createdBy | string, number, string | Start in UTC; arena length; the account that created it |
rank, username, playerTitle, rating | number, string, string, number | The place in the standing |
score | number | Arena score, or the swiss points — filled for both formats |
points, tieBreak | number | Swiss only |
performance | number | Tournament performance rating. Filled when the standing is read as a stream; when the actor has to fall back to the paged standing, only the podium has it and the status message says so |
sheetScores, fire | string, boolean | Arena streak sheet, when the paged standing is used |
absent | boolean | Withdrew or did not appear |
A leaderboard row
Real row from cloud run L0hHlFgGu7BBBb9Vr:
{"perfType": "blitz","rank": 1,"username": "Tuzakli_Egitim","userId": "tuzakli_egitim","playerTitle": "FM","rating": 2984,"progress": 11,"online": false,"patron": false,"url": "https://lichess.org/@/Tuzakli_Egitim","fetchedAt": "2026-09-30T22:22:30.993Z"}
| Field | Type | Meaning |
|---|---|---|
perfType, rank | string, number | Which list, and the place in it |
username, userId, playerTitle | string | The account |
rating, progress | number | Current rating and its recent movement |
online, patron | boolean | Live flags |
A row that found nothing
An account or tournament id the source does not know — and an account whose rating history the source withholds (Limits) — produces a row instead of a silent empty run:
{ "found": false, "notFoundReason": "no Lichess account named \"almatyy_chess\" — the account does not exist or was renamed","username": "almatyy_chess", "url": "https://lichess.org/@/almatyy_chess", "fetchedAt": "2026-09-30T22:00:00.000Z" }
The second case, real row from cloud run OabdaaIOJTXNIVYUa — the account exists and plays, the source did not hand
over its curve on those three attempts:
{"found": false,"notFoundReason": "lichess.org published no rating history for this account although it has 75502 rated game(s) (asked 3 time(s), every answer 200 with an empty list) — the endpoint does this for part of the accounts at a time and the same account often answers minutes later, so repeat the run; mode \"player\" returns the current rating of every speed straight away","fetchedAt": "2026-09-30T23:03:47.460Z","username": "penguingim1","url": "https://lichess.org/@/penguingim1","perfType": null, "perfName": null, "date": null, "rating": null, "ratingChange": null}
The same run wrote 30 real rating points for the first of its three accounts, so one withheld curve costs one row, not the run.
Dataset views and the SUMMARY record
Five views, one per mode: Games, Players, Rating history, Tournament standings, Leaderboard. Open the one named after your mode; the others stay empty for that run.
Every run also writes a SUMMARY record into the default key-value store (linked from the run's output as
Run summary): mode, the filters as sent, the number of requests and of 429 answers, rows read, rows written, rows
per account, accounts or ids that were not found or not read, how many games were dropped by analysedOnly, how many
were already known to onlyNew, the source errors, the warnings, and stoppedBy (limit, timeout or aborted)
when the run ended early.
Use it from code / agents
curl -X POST "https://api.apify.com/v2/acts/yadroo~lichess-games/run-sync-get-dataset-items?token=$APIFY_TOKEN" \-H "Content-Type: application/json" \-d '{"mode":"games","usernames":["DrNykterstein"],"perfTypes":["blitz"],"rated":"rated","maxItems":20}'
import { ApifyClient } from 'apify-client';const client = new ApifyClient({ token: process.env.APIFY_TOKEN });const run = await client.actor('yadroo/lichess-games').call({mode: 'games',usernames: ['cutemouse83'],sinceHours: 168,fields: ['gameId', 'opponent', 'result', 'openingName', 'createdAt'],maxItems: 50,});const { items } = await client.dataset(run.defaultDatasetId).listItems();
from apify_client import ApifyClientclient = ApifyClient(os.environ["APIFY_TOKEN"])run = client.actor("yadroo/lichess-games").call(run_input={"mode": "ratingHistory", "usernames": ["thibault"], "perfTypes": ["blitz"], "sinceDays": 365, "maxItems": 400,})items = client.dataset(run["defaultDatasetId"]).list_items().items
fields is what makes this usable as a tool for an agent: a games row has more than forty columns, and
["gameId", "opponent", "result", "openingName", "movesCount", "createdAt"] is usually all a model should see. One
call answers one question, and the rows are flat JSON with no nesting to walk.
MCP: add https://mcp.apify.com to Claude / Cursor / any MCP client and call the yadroo/lichess-games tool with the
same JSON input.
Pricing
Pay per event: $0.001 per run start + $0.001 per dataset row. Every run is charged the start event, including a run that finds nothing.
| Run | Rows | Cost |
|---|---|---|
One account looked up in player mode | 1 | $0.001 + $0.001 = $0.002 |
| The 50 newest games of two accounts (the default) | 50 | $0.001 + $0.050 = $0.051 |
| A year of blitz rating points for a chart | 300 | $0.001 + $0.300 = $0.301 |
| The top 200 of one speed | 200 | $0.001 + $0.200 = $0.201 |
maxItems is the brake: it is a hard cap on the rows written, so it is also a hard cap on the bill. Apify plan
discounts apply to the row price — 10 % on Bronze, 20 % on Silver, 30 % on Gold and above — while the start event costs
the same on every plan. The 50-row run above therefore costs $0.036 on a Gold plan.
Platform usage is included in these prices — there is no compute or proxy charge on top. The runs behind this README finished in 3 to 30 seconds each, the long ones only because the source insists on one request at a time.
Your maximum cost per run is respected exactly: the run writes only the rows it pays for and ends with the status "Stopped at your spending limit: N rows delivered", so a cap never leaves you with rows charged but missing.
Limits & FAQ
- One request at a time. The Lichess API asks third-party applications to send one request at a time and, after a
429, to wait a full minute before asking again. This actor therefore serialises every call, pauses between them, and after a429waits a full minute (longer if the answer'sRetry-Aftersays so) before its next request of any kind — the tournament fallback included. Consequence: ten accounts take about ten times as long as one. It also means you should not start ten runs of this actor in parallel from the same account. - Long exports are streamed. The games export arrives at about 20 games per second, so 2,000 games take around 100 seconds. Rows are written as the games arrive; only a stall (no data for 30 seconds) counts as an error, and a stream that breaks off is resumed from the last game received.
- Timeout and spending limit. Shortly before the run's timeout the actor stops starting new requests, saves what it has read and ends as a success with "Stopped before the run timeout: N rows saved". At your maximum cost per run it ends with "Stopped at your spending limit: N rows delivered". Either way the SUMMARY names what was not read.
- A very active player has tens of thousands of games.
maxItemsis the only brake, and ingamesmode the cap is split evenly overusernames— 60 rows over three accounts is about 20 games each, not 60 games of the first one. Accounts with fewer games leave their share to the ones that follow. - Clocks, evaluations and accuracy exist only where the game has them. Most bullet games were never analysed by the
server, so
playerAccuracy,playerAcpland the blunder counts are empty for them; theanalysedcolumn says which rows to trust.includeEvalscombined withanalysedOnlyavoids paying for rows that come back empty. - Ratings of speeds never played are empty on purpose. The source reports a provisional starting value (1500, or
2500 for a strong new account) with
games: 0. Writing that as a rating would invent data, so the column stays empty and the count tells the story. - The rating history's zero-based month is normalised for you. The source encodes a point as
[year, monthIndex0, day, rating], so[2017, 3, 10, 1380]is 2017-04-10. Ports that miss this are off by a month. - The rating history is the one mode the source does not always answer. Measured on 2026-09-30 over twelve
well-known accounts and two networks: the rating-history endpoint replies
200with an empty list for part of the accounts at any given moment — accounts with 10 000, 19 000 and 57 000 rated games among them — while other accounts asked in the same minutes return their full curves. No429and noRetry-Aftercome with it, so it is not a rate limit and not something a caller can avoid; it is also not stable. One account answered with an empty list seven times in a row over an hour and then returned 32 kB of curves; another answered 148 kB to one network and an empty list to another four minutes later; a third stayed empty all evening. It behaves like a cache on the source's side that can hold an empty value for a while. What the actor does about it: an empty answer is indistinguishable from "this account never played rated", so it asks the profile how many rated games the account has, and if there are any it asks the history again after 2 and 6 seconds. If it is still empty, the run writes one row withfound: falseand that explanation — it does not claim the account has no rated games, and it does not fail. The other accounts of the same run keep their curves, and you pay the start event plus that one row. The two things that do work: run it again later (often minutes are enough), andmode: "player", which returns the current rating of every speed and always answers. Naming two or threeusernamesin oneratingHistoryrun is the practical hedge, which is why the example task does. - Correspondence and puzzles have no top list. That is why
leaderboardPerfhas 13 values and not 15. - Arena standings are read as a stream when the source allows it. That path carries the tournament performance rating for everyone. When it is rate-limited, the actor falls back to the paged standing (10 rows per request), where only the podium has a performance — the status message says which path was used.
- Swiss tournaments produce
pointsandtieBreakinstead of an arena score, and noperfType: the swiss endpoint does not publish the rating pool the arena endpoint does. The clock, the variant and the player count are there. - Private and imported games are not visible. The export shows what the account's own privacy settings publish; games imported into a study and games an owner has hidden need that owner's personal token, which this actor does not use and does not ask for.
- Public data only. No login, no captcha, no anti-bot circumvention, no proxy. The free-text profile (real name, biography, links) is not written to any row and there is no contact field in the source to read.
- Empty results versus errors. An account or tournament id the source does not know produces a
found: falserow, and so does an account whose rating history the source withholds. Ingamesmode a filter combination that matches nothing produces no rows and a status message naming the filter that emptied the run. An input that cannot be read at all — nousernamesin a mode that needs them,dateTobeforedateFrom— fails immediately with the reason, before any request. A run fails otherwise only when it wrote nothing and the source was the reason: unreachable after the backoff, or a429that outlasted it. A run that wrote some rows always ends as a success, with the accounts that went wrong named in the status message and listed undersourceErrorsin the SUMMARY record. - If Lichess changes or restricts the export, this section will say the actor is paused. Working around a restriction is not something this actor does.
Made by Yadroo. Sibling actors: arxiv-papers · openalex-works · wikipedia-search · hackernews-search · stackexchange-search