Chess Cheat Detection
A proof of concept, not a cheat detector. One month of Lichess games, about 1.5 million of them, parsed in DuckDB with nothing but SQL. Every player with 50 or more games gets compared with what the Elo formula says they should have scored, the biggest overperformers get flagged, and a short script asks the Lichess API which of them were later marked for cheating.
Period
- Oct 2026
Role
- Sole engineer
Type
- Source on GitHub
Tools
The idea
A chess rating is a prediction. If I'm rated 1500 and you're rated 1800, the Elo formula says I should score about 15% against you. Someone who keeps beating that prediction by a wide margin is either improving unusually fast or getting help from an engine. Lichess publishes every rated game, and it marks accounts that break its terms of service, so I could test the idea instead of just arguing it.
To be clear about scope: this looks at a couple of things that ratings and results can show, to see whether anything turns up. It isn't a foolproof way to find cheating and isn't meant to be. Lichess publishes every month since 2013 as its own download and the same SQL works on any of them by changing one filename. I picked January 2015 because it's small, about 290 MB. The newest months are around 30 GB and would run the same way, just slower.
How it works
DuckDB reads the compressed game file as raw text, keeps the header tags, and pivots them into one row per game. Each game becomes two rows, one per player, with the points they scored. For every player with 50 or more games I average their actual points and the points the formula expected given each opponent's rating. The gap between the two is their edge. An edge of 0.15 or more, which is scoring like someone rated about 100 points higher, makes them a suspect.
Then a twenty line Python script sends the suspects and a random control group of 300 other active players to the Lichess API and counts the accounts with a terms of service violation.
I wrote the analysis SQL and the Python script by hand. The script that converts the raw Lichess file into tables, build.sql, I generated with AI.
Results
On January 2015, 11,649 players had 50 or more games and 52 of them were suspects. 18 of the 52 (34.6%) were later marked by Lichess for cheating, against 17 of the 300 players in the control group (5.7%). One rule, computed from nothing but ratings and results, finds cheaters at about six times the base rate. The whole pipeline runs in under twenty seconds.
Limits
A real detector would look at far more than this. This only sees results, not moves: the 2015 file has no move times and engine evaluations on only a few games, so it can't tell a cheater from a strong player on a new account with a provisional rating. A terms of service mark can also come years later for something other than cheating, so the 35% is a rough answer. The next version would parse the engine evaluations and look at move quality directly.
Show repo structureHide repo structure
chess_cheat_detection/
build.sql turns the Lichess text file into tables inside lichess.db
analysis.sql scores every player and writes suspects.csv and control.csv
check_bans.py asks the Lichess API which players were marked for cheating
README.md
images/ screenshots of the run
Downloaded or generated, never committed:
lichess_db_standard_rated_2015-01.pgn.zst
lichess.db
suspects.csv
control.csvShow sample SQL codeHide sample SQL code
-- One row per player with 50 or more games: the points they scored,
-- the points the Elo formula expected, and the gap between the two.
CREATE OR REPLACE TABLE profile AS
SELECT player,
COUNT(*) AS games,
ROUND(AVG(elo)) AS avg_elo,
ROUND(AVG(score), 3) AS actual,
ROUND(AVG(1 / (1 + POWER(10, (opp_elo - elo) / 400.0))), 3) AS expected,
ROUND(AVG(score) - AVG(1 / (1 + POWER(10, (opp_elo - elo) / 400.0))), 3) AS edge
FROM player_games
WHERE elo IS NOT NULL AND opp_elo IS NOT NULL
GROUP BY player
HAVING COUNT(*) >= 50;
-- Suspects score at least 0.15 points per game more than their rating predicts.
CREATE OR REPLACE TABLE suspects AS
SELECT *
FROM profile
WHERE edge >= 0.15;
