# Lichess Games Export: Moves, Openings, Ratings, Tournaments (`yadroo/lichess-games`) Actor

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.

- **URL**: https://apify.com/yadroo/lichess-games.md
- **Developed by:** [Samat Makatov](https://apify.com/yadroo) (community)
- **Categories:** Developer tools, AI, Other
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.70 / 1,000 chess data rows

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

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"` with `includeMoves`, `includeClocks` and `includeAccuracy`: 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. `openingName` and `openingEco` make a repertoire table a group-by away.
- **Watch an account on a schedule** — `sinceHours: 24` with `onlyNew: 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: false` with `onlyNew: true` and a `maxItems` per 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](#limits--faq) 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 into `games` mode.

### 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](#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](#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](#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](#arena-and-swiss) |
| `leaderboardPerf` | string | `blitz` | One of the 13 [top-list speeds and variants](#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](#limits--faq) |
| `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](#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](#limits--faq) |
| `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**

```json
{ "mode": "games", "usernames": ["DrNykterstein"], "perfTypes": ["bullet", "blitz"], "rated": "rated", "maxItems": 20 }
```

**Games with moves, clocks and accuracy, ready for an engine or a model**

```json
{ "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**

```json
{ "mode": "games", "usernames": ["cutemouse83"], "sinceHours": 336, "onlyNew": true, "includeMoves": false, "maxItems": 20 }
```

**One colour of one season, for opening work**

```json
{ "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**

```json
{ "mode": "player", "usernames": ["DrNykterstein", "cutemouse83", "thibault"], "maxItems": 10 }
```

**Five years of blitz, rapid and puzzle ratings of three accounts, for one chart**

```json
{ "mode": "ratingHistory", "usernames": ["thibault", "penguingim1", "nihalsarin2004"],
  "perfTypes": ["blitz", "rapid", "puzzle"], "sinceDays": 1825, "maxItems": 90 }
```

**The final table of an arena tournament**

```json
{ "mode": "tournament", "tournamentKind": "arena", "tournamentIds": ["ayELljKv"], "maxItems": 30 }
```

**The current blitz top 20**

```json
{ "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 `…`):

```json
{
  "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](#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`:

```json
{
  "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`:

```json
{
  "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`:

```json
{
  "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`:

```json
{
  "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](#limits--faq)) — produces a row instead of a silent empty run:

```json
{ "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:

```json
{
  "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

```bash
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}'
```

```js
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();
```

```python
from apify_client import ApifyClient
client = 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 a `429` waits a full minute (longer if the answer's `Retry-After` says 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.** `maxItems` is the only brake, and in `games` mode the cap is
  split evenly over `usernames` — 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`, `playerAcpl` and the blunder counts are empty for them; the `analysed` column says which
  rows to trust. `includeEvals` combined with `analysedOnly` avoids 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 `200` with 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. No `429` and no `Retry-After` come 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** with `found: false` and 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), and `mode: "player"`, which returns the current rating of every speed and always answers.
  Naming two or three `usernames` in one `ratingHistory` run is the practical hedge, which is why the example task does.
- **Correspondence and puzzles have no top list.** That is why `leaderboardPerf` has 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 `points` and `tieBreak` instead of an arena score, and no `perfType`: 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: false` row,
  and so does an account whose rating history the source withholds. In `games` mode 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 — no `usernames` in a mode that needs them, `dateTo` before `dateFrom` — 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 a `429` that 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 under `sourceErrors` in 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](https://apify.com/yadroo/arxiv-papers) ·
[openalex-works](https://apify.com/yadroo/openalex-works) ·
[wikipedia-search](https://apify.com/yadroo/wikipedia-search) ·
[hackernews-search](https://apify.com/yadroo/hackernews-search) ·
[stackexchange-search](https://apify.com/yadroo/stackexchange-search)

# Actor input Schema

## `mode` (type: `string`):

Five questions, five row shapes, one per run. `games` is the export the other four support: every finished game of the accounts you name, newest first, with the filters below applied by the source. `player` answers "how strong is this account and how much has it played". `ratingHistory` returns the daily rating points behind those numbers, one row each, so a chart needs no post-processing. `tournament` turns a tournament id into its final standing. `leaderboard` is the current top list of one speed or variant. Open the dataset view that carries the mode's name; the other views stay empty.

## `usernames` (type: `array`):

Accounts to read, as they appear after the @ in a profile URL (`lichess.org/@/DrNykterstein` -> `DrNykterstein`). Case does not matter - the source matches accounts by their lower-case id. Used by the `games`, `player` and `ratingHistory` modes; ignored by `tournament` and `leaderboard`. Accounts are processed in the order given, one request at a time. An account that does not exist, is closed or was renamed yields a single row with `found: false` and the reason in `notFoundReason`, so a typo in a long list never disappears silently.

## `perfTypes` (type: `array`):

Keep only these speeds or variants. Empty = everything the account has played. Several values = OR, and the filter is sent to the source, so games outside it are never fetched or charged. `puzzle` is not a game type: it exists only as a rating curve, so it is accepted in `ratingHistory` mode and ignored in `games` mode.

## `rated` (type: `string`):

Rated games move the account's rating and are what rating analysis needs; casual games include warm-ups, odds games and games against friends. Titled players often have thousands of casual games, so this is usually the first filter to set.

## `color` (type: `string`):

Restrict the export to the games in which the named account played that colour. Useful for opening work, where the repertoire question is asked per colour.

## `opponent` (type: `string`):

A second username: keep only the games the named accounts played against this one account, in either colour. That is the head-to-head record two players ask for before a match. Leave empty for all opponents. If the two accounts never met, the run returns no rows and says so in the status message rather than widening the search.

## `sinceHours` (type: `number`):

Rolling window in hours, counted in UTC from the moment the run starts: 24 for the last day, 168 for a week, 720 for a month. The source compares it with the moment a game started, so a game begun just before the window and finished inside it is not included. This is the input to use on a schedule - it needs no date editing between runs. It takes precedence over `dateFrom`; empty = no lower bound.

## `dateFrom` (type: `string`):

Fixed lower bound of the date window, as a calendar date (`2026-01-01`) or a full UTC timestamp (`2026-01-01T00:00:00Z`). Ignored when `sinceHours` is set. The source compares it with the moment a game started.

## `dateTo` (type: `string`):

Fixed upper bound of the date window, exclusive, compared with the moment a game started. Combine it with `dateFrom` to pull one season, one month or one tournament week out of a long career.

## `analysedOnly` (type: `boolean`):

Keep only games for which a full computer analysis exists, which are the games that carry accuracy, average centipawn loss and the blunder counts. Most bullet games have none, so switching this on can shrink a run to a handful of rows - raise the row limit or widen the date window when you use it.

## `onlyNew` (type: `boolean`):

Remember the game ids of this run in a named key-value store of your account and, on the next run of the same actor or task, write only games that were not written before. Only games really written (and charged) are remembered, so a run stopped by your spending limit or the timeout loses nothing. Made for schedules: with newest games first every run holds just the games played since the last run, and an empty run means the player has not played; with `sortDescending` off, each run continues the career forwards from the newest game the previous runs wrote. The memory is per task (runs started by hand share one), so two tasks watching two players do not interfere.

## `sortDescending` (type: `boolean`):

On by default, which is what a row limit should be combined with: 50 rows then means the 50 most recent games. Switch it off to walk a career forwards from its first game, together with `dateFrom` - and with `onlyNew`, so that each run picks up where the previous one stopped.

## `includeMoves` (type: `boolean`):

Add the moves of the game in standard algebraic notation as one space-separated string (`e4 e5 Nf3 Nc6 ...`), plus the move count. This is what an engine, a parser or a language model reads. Switch it off for a table of results only, which makes rows much smaller.

## `includeOpening` (type: `boolean`):

Add the opening as the source classifies it: the ECO code (`B90`), the full name with variation (`Sicilian Defense: Najdorf Variation`) and the number of plies the classification covers. Short games and non-standard variants are left unclassified.

## `includeClocks` (type: `boolean`):

Add the remaining time of both players after each move, in centiseconds, as an array in move order. That is the raw material for time-trouble and time-management analysis; it makes a bullet game row several kilobytes long.

## `includeEvals` (type: `boolean`):

Add the per-move evaluation of the server analysis, when one exists: the score in centipawns or the mate distance, plus the move the engine preferred. Present only for analysed games; combine with `analysedOnly` so you do not pay for rows that come back empty.

## `includeAccuracy` (type: `boolean`):

Add each side's accuracy percentage, average centipawn loss and the counts of inaccuracies, mistakes and blunders from the server analysis. Empty for games that were never analysed, which the `analysed` column marks.

## `sinceDays` (type: `number`):

In `ratingHistory` mode, keep only points from the last N days: 365 for a year of form, 30 for a month. Empty = the whole history, which for an account opened in 2010 can be a few thousand points across all variants. The source records one point per day on which the rating changed, so gaps in the output are days without games, not missing data.

## `tournamentIds` (type: `array`):

The ids from tournament URLs: `lichess.org/tournament/ayELljKv` -> `ayELljKv` for an arena, `lichess.org/swiss/<id>` -> that id for a swiss. Finished tournaments stay readable, so an id keeps working long after the event. Several ids in one run produce one dataset with a `tournamentId` column. An unknown id yields one row with `found: false`.

## `tournamentKind` (type: `string`):

The two formats live under different URLs and different endpoints, and an arena id is not a swiss id. Arena rows carry the running score and the streak sheet; swiss rows carry points and the tie-break number instead.

## `leaderboardPerf` (type: `string`):

Which top list `leaderboard` mode reads. Correspondence and puzzles have no public top list, which is why they are missing here. Each row carries the rating, the recent progress and whether the account is online, so the list doubles as a source of active strong accounts to feed back into `games` mode.

## `topCount` (type: `number`):

Length of the top list, up to the 200 the source publishes. The row limit below still applies, so the smaller of the two wins.

## `maxItems` (type: `number`):

Hard cap on the rows this run writes, and therefore on what it costs. Reading stops as soon as the cap is reached - the games stream is closed early instead of being downloaded and thrown away. In `games` mode the cap is divided over the accounts you named. The run also stops at your maximum cost per run, and shortly before the run timeout, saving what it has read and saying so in the status message.

## `fields` (type: `array`):

Keep only the named columns, in the order you list them - for example `gameId, opponent, result, openingName, createdAt`. The mode's key columns are always kept, so a row still says what it is about: `gameId` and `player` in games mode, `username` and `found` in player mode, `username` and `perfType` in rating-history mode, `tournamentId` and `found` in tournament mode, `perfType` and `rank` in leaderboard mode; a row with `found: false` also keeps `found` and `notFoundReason`. Empty = every column of the mode. Letter case does not matter, and the words of a name may be joined or separated by spaces, `_` or `-` (`GameID`, `game_id` and `Game ID` all mean `gameId`; `opening_name` means `openingName`); a name that is not a column is reported in the status message with the closest column, and a list in which no name is a column stops the run before any request.

## Actor input object example

```json
{
  "mode": "games",
  "usernames": [
    "DrNykterstein",
    "cutemouse83"
  ],
  "rated": "any",
  "color": "any",
  "analysedOnly": false,
  "onlyNew": false,
  "sortDescending": true,
  "includeMoves": true,
  "includeOpening": true,
  "includeClocks": false,
  "includeEvals": false,
  "includeAccuracy": true,
  "tournamentIds": [
    "ayELljKv"
  ],
  "tournamentKind": "arena",
  "leaderboardPerf": "blitz",
  "topCount": 20,
  "maxItems": 50
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `summary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "usernames": [
        "DrNykterstein",
        "cutemouse83"
    ],
    "tournamentIds": [
        "ayELljKv"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("yadroo/lichess-games").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "usernames": [
        "DrNykterstein",
        "cutemouse83",
    ],
    "tournamentIds": ["ayELljKv"],
}

# Run the Actor and wait for it to finish
run = client.actor("yadroo/lichess-games").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "usernames": [
    "DrNykterstein",
    "cutemouse83"
  ],
  "tournamentIds": [
    "ayELljKv"
  ]
}' |
apify call yadroo/lichess-games --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,yadroo/lichess-games"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/8tidt7K64arJy4rZh/builds/b2RnOy3hExSh0ctUy/openapi.json
