Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Open PQL is a high-performance Rust implementation of the Poker Query Language (PQL), enabling SQL-like queries for poker analysis and calculations. It is a spiritual successor to the original Java implementation developed by Odds Oracle.

⚠️ Work in Progress: This project is under active development and is not yet ready for production use.

What is PQL?

PQL lets you ask questions about poker situations in a declarative, SQL-like syntax:

select equity
from   hero='AhKh', villain='QQ+', board='Ah9s2c', game='holdem'

This computes hero’s equity against villain’s range on a given flop.

Why Open PQL?

  • Familiar syntax — SQL-like grammar is easy to learn if you know databases.
  • High performance — written in Rust, with Monte Carlo sampling for fast estimates.
  • Multi-game support — Texas Hold’em and other variants.
  • Library + CLI — use the opql command or embed the runner crate in your code.

Workspace Crates

CratePurpose
openpql-preludeCore poker types: cards, hands, evaluators
openpql-coreGame abstraction and query execution core
openpql-range-parserParser for range notation (AA-TT, AwKw+, …)
openpql-pql-parserParser for PQL syntax
openpql-runnerQuery executor and opql CLI
openpql-macroInternal procedural macros

How to Read This Book

Try It Online

An interactive demo is available at https://pql-playground.solve.poker.

Installation

Open PQL can be used in two ways: as a command-line tool (opql) or as a library embedded in a Rust program.

Requirements

  • Rust 1.85 or newer (edition 2024)
  • A recent cargo toolchain

Install the CLI

Clone the repository and build the runner crate:

git clone https://github.com/solve-poker/Poker-Query-Language.git
cd Poker-Query-Language
cargo install --path openpql-runner

This installs the opql binary into ~/.cargo/bin/. Check it’s on your PATH:

opql --help

Use as a Library

Add the runner crate to your Cargo.toml:

[dependencies]
openpql-runner = "0.1.0"

See Library Usage for integration details.

Build From Source

If you plan to contribute or run tests, clone the workspace and use the provided justfile:

just build      # cargo build
just test       # cargo nextest run
just lint       # cargo clippy
just doc        # cargo doc --no-deps

Next Step

Continue to Your First Query.

Your First Query

Let’s compute hero’s equity against a range of villain hands.

The Query

select equity
from   hero='AhKh', villain='QQ+', board='Ah9s2c', game='holdem'

Reading left to right:

  • select equity — ask for the equity selector
  • hero='AhKh' — hero holds the Ace and King of hearts
  • villain='QQ+' — villain has any pocket pair QQ or better
  • board='Ah9s2c' — the community cards on the flop
  • game='holdem' — play the hand as Texas Hold’em

Run It

opql --run "select equity from hero='AhKh', villain='QQ+', board='Ah9s2c', game='holdem'"

The runner samples random runouts (turn + river) and prints the resulting equity. Re-run the query for a fresh Monte Carlo estimate.

A Second Example

Average number of suited cards on the river when hero holds A♠9♠ and the flop already has two spades:

opql --run "select avg(boardsuitcount(river)) from hero='As9s', villain='*', board='2s3sJh', game='holdem'"

Here villain='*' means any two cards, and avg(...) averages a per-trial statistic across samples.

Next Step

Learn more about the CLI flags in CLI Basics, or skip ahead to Query Structure.

CLI Basics

The opql binary is a thin wrapper around the runner crate. It currently exposes a single entry point, --run, that executes a PQL string and writes the result to stdout.

Usage

opql --run "<PQL query>"

Example:

opql --run "select equity from hero='AA', villain='KK', board='AhKh2c', game='holdem'"

Notes

  • Quote the full query with double quotes so the shell passes it as one argument.
  • Use single quotes inside the query for hand, range, and board literals.
  • Errors in parsing or evaluation are written to stderr; successful results go to stdout.

Getting Help

opql --help

For richer workflows — batching queries, scripting with results, custom trial counts — use the library API directly.

Query Structure

Every PQL query follows a select … from … shape, modeled after SQL.

select <selectors>
from   <bindings>

Selectors

Selectors are the things you want to measure. Each selector can optionally be aliased with as:

select equity, avg(boardsuitcount(river)) as suits
from   ...

Selectors generally fall into three categories:

  1. Direct values — e.g. equity, a scalar produced per trial.
  2. Aggregates — e.g. avg(expr), count(expr), reducing across trials.
  3. Boolean predicates — used inside aggregates to count probabilities.

Bindings (the from clause)

Bindings declare the poker situation to simulate. The most common ones are:

BindingMeaning
hero='…'Hero’s exact cards or range
villain='…'Villain’s cards or range (multiple villains allowed)
board='…'Community cards dealt so far
game='holdem'Which poker variant to play

See From Clause for the full list and Range Notation for how the string values are parsed.

Case Sensitivity

Keywords (select, from, as, function names) are case-insensitive. Card and suit literals follow standard notation: rank characters 2-9, T, J, Q, K, A and suit characters s, h, d, c.

Whitespace and Commas

Whitespace is free-form. Selectors and bindings are separated by commas. Trailing commas are tolerated.

Selectors

A selector is an expression that produces a value for each simulation trial. Multiple selectors are separated by commas and may be aliased with as.

Scalar Selectors

A scalar selector returns one value per trial. The simplest is equity, which yields hero’s equity share on that trial.

select equity from hero='AhKh', villain='QQ+', board='Ah9s2c', game='holdem'

Aggregates

Aggregates reduce many trials into a single number.

AggregateResult
avg(expr)Mean of expr across trials
count(predicate)Probability of predicate being true

Example — frequency the river is a spade:

select count(riversuit = 's') as pct_spade_river
from   hero='AsKs', villain='*', board='2s3h7d', game='holdem'

Aliases

Use as to give a selector a readable name:

select avg(boardsuitcount(river)) as river_suits
from   hero='As9s', villain='*', board='2s3sJh', game='holdem'

Aliases must be unique inside a query.

Nesting

Functions can be composed — an aggregate can wrap a function of a board function, etc. See Built-in Functions for the list of operators you can combine.

From Clause

The from clause defines the scenario PQL will simulate. Each binding is name=value where the value is quoted.

Standard Bindings

game

Selects the poker variant:

game='holdem'

See Supported Games for the full list.

hero and villain

Declare each player’s holding or range:

hero='AhKh'        -- a specific two-card hand
villain='QQ+'      -- a range: any pocket pair QQ or better
villain='*'        -- any two cards (fully random)

Multiple villains can be declared by repeating the name, or by using villain1, villain2, etc. depending on the scenario being modeled. See Range Notation for the full syntax of the right-hand-side string.

board

The community cards dealt so far. The length of the string determines the street the simulation starts from:

  • 0 cards (board='' or omitted): preflop
  • 3 cards: flop
  • 4 cards: turn
  • 5 cards: river (no further dealing)
board='Ah9s2c'          -- flop
board='Ah9s2c7d'        -- turn
board='Ah9s2c7dTs'      -- river

Ordering

Bindings may appear in any order. By convention, declare hero first, then villains, then board, then game.

Range Notation

Ranges describe sets of starting hands. Open PQL uses a generic, variable-based notation (not the classic AKs/AKo shorthand).

Suit Variables

Suits are written as w, x, y, z. Same letter = same suit. Different letters = different suits.

  • AwKw — Ace and King, same suit (suited AK)
  • AxKy — Ace and King, different suits (offsuit AK)
  • AK — Any AK (suited or offsuit)

Concrete suits (s, h, d, c) lock to a specific card:

  • AsKh — Exactly the Ace of spades and King of hearts

Atoms

NotationMeaning
AsKhExact two cards
AwKwSuited AK
AxKyOffsuit AK
AKAny AK
TTAny pocket tens
*Any two cards

Spans

NotationMeaning
QQ+Pocket pairs QQ or better
88-55Pocket pairs from 88 down to 55
AwJw+Suited aces from AJ up
KwQw-KwTwSuited kings from KQ down to KT

Lists

[2,4,6,8,T]A expands to A2, A4, A6, A8, AT.

Combining

Combine atoms with commas in a single quoted string:

AA, KK, AwKw, 77-55

Conflicts with the Board

Combos that collide with known cards (other players’ holdings or the board) are excluded automatically during simulation. You don’t need to subtract blockers by hand.

The grammar lives in openpql-range-parser. Invalid ranges surface as parse errors at query evaluation time.

Boards and Streets

PQL simulates complete runouts, so it always has a concept of “current street” based on how many cards you provided in board=.

Streets

StreetBoard sizeCards sampled each trial
Preflop0flop + turn + river
Flop3turn + river
Turn4river
River5none — deterministic

Referencing Streets in Functions

Many functions take a street argument so you can ask about the board as it will look on a future street:

select avg(boardsuitcount(river))
from   hero='As9s', villain='*', board='2s3sJh', game='holdem'

Here river refers to the completed 5-card board, even though the simulation starts on the flop.

Typical street selectors accepted by board-aware functions:

  • flop — the first three community cards
  • turn — the fourth card
  • river — the fifth card (or the complete 5-card board, depending on the function)

Fixed Boards

If board is a full 5-card string, no cards are sampled. The query becomes a deterministic evaluation, useful for checking concrete spots.

Dead Cards

Any card in hero, villain, or board is removed from the deck for the rest of the deal. This prevents impossible combinations from being generated.

Supported Games

The game='…' binding selects the poker variant. Each variant changes the number of hole cards and the hand evaluator.

ValueVariantHole cardsDeck
holdemTexas Hold’em2Full 52
omahaOmaha Hi4Full 52
shortdeckShort-Deck Hold’em236 (6s–As)

Holdem

The default. Players are dealt two hole cards, share a five-card board, and use any combination of seven cards to make the best five-card hand.

Omaha

Four hole cards per player, but each player must use exactly two from their hand and three from the board. Range strings still use the same notation; hand literals require four cards (e.g. AhAsKhKs).

Short Deck

A 36-card deck (deuces through fives removed). Straights use A-6-7-8-9 as the low straight, and flushes beat full houses in some rulesets. The prelude crate’s evaluator implements the common short-deck ranking.

Changing the Game

Each query picks a single game. You cannot mix variants inside one query.

select equity
from   hero='AhAsKhKs', villain='*', board='', game='omaha'

Built-in Functions Overview

PQL ships with a library of poker-specific functions you can use inside select. They group loosely into the categories below; each has its own page with signatures and examples.

CategoryFunctionsPage
Equityequity, hvhequity, minHvHEquity, riverEquity, fractionalRiverEquityEquity
Hand categoriesflopHandCategory, exactFlopHandCategory, minFlopHandCategory, overpair, pocketPairHand Categories
Board textureboardSuitCount, flushingBoard, monotoneBoard, pairedBoard, rainbowBoard, twotoneBoard, straightBoard, turnCard, riverCardBoard Texture
Rank utilitiesboardRanks, handRanks, maxRank, minRank, nthRank, rankCount, hasTopBoardRank, hasSecondBoardRank, handBoardIntersections, and moreRank Utilities
OutsnutHi, nutHiForHandTypeOuts

Common Shapes

Most functions take one or more of:

  • Street: flop, turn, river
  • Player: hero, villain
  • Hand / board expressions: compose with other functions

Function names are case-insensitive. The examples in this book use camelCase for readability, but boardSuitCount, boardsuitcount, and BOARDSUITCOUNT are all valid.

Where Functions Live in the Source

The function implementations live under openpql-runner/src/functions/. If the book lags behind, the source is the source of truth.

Equity

Equity functions estimate each player’s share of the pot at showdown, averaged over sampled runouts.

equity

Hero’s equity against all declared villains.

select equity
from   hero='AhKh', villain='QQ+', board='Ah9s2c', game='holdem'

hvhEquity(p1, p2)

Head-to-head equity between two named players. Useful when several villains are declared and you want a specific matchup.

select hvhEquity(hero, villain)
from   hero='AA', villain='KK', board='', game='holdem'

minHvHEquity(hero, villain1, villain2, …)

The minimum head-to-head equity hero has across each listed villain. Useful for worst-case analysis against multiple opponents.

riverEquity

Equity evaluated strictly on the river — i.e. no further sampling because the board is fully known. Equivalent to equity when board is 5 cards.

fractionalRiverEquity

Like riverEquity, but awards fractional pot shares for chopped pots rather than splitting the win 50/50 per trial.

Tips

  • Equity over a range is the combo-weighted average of per-combo equities.
  • For preflop all-in spots, leave board=''.
  • The runner uses Monte Carlo sampling; re-run to get fresh estimates.

Hand Categories

These functions classify a player’s made hand on a given street.

flopHandCategory(player)

Returns the general made-hand category a player has on the flop, such as “top pair”, “set”, “flush draw”, etc.

select count(flopHandCategory(hero) = 'topPair') as pct_toppair
from   hero='AwKw', villain='*', board='As7d2c', game='holdem'

exactFlopHandCategory(player)

A finer-grained version — distinguishes sub-categories (e.g. “top two” vs. “bottom two”, “combo draw” vs. “flush draw only”).

minFlopHandCategory(player)

Returns the minimum hand category a player has, combined with one draw. Useful for “at least pair + flush draw” style filters.

pocketPair(player)

Boolean — true if the player holds a pocket pair.

overpair(player)

Boolean — true if the player holds a pocket pair strictly higher than the highest board card.

select count(overpair(hero)) as pct_overpair
from   hero='QQ+', villain='*', board='Jc7d2s', game='holdem'

Compare With

  • Rank Utilities — for lower-level questions about which ranks appear in a hand.
  • Outs — for the number of outs to a specific made hand.

Board Texture

Board texture functions describe what the community cards look like, independent of any player’s hand.

boardSuitCount(street)

The number of distinct suits present on the board at the given street.

select avg(boardSuitCount(river))
from   hero='As9s', villain='*', board='2s3sJh', game='holdem'

rainbowBoard(street) / twotoneBoard(street) / monotoneBoard(street)

Boolean predicates for three-suit, two-suit, and one-suit boards.

flushingBoard(street)

True if the board itself contains three or more cards of the same suit (i.e. a flush is possible on the board alone).

pairedBoard(street)

True if at least two board cards share a rank.

straightBoard(street)

True if the board itself contains a made straight.

turnCard / riverCard

The single card dealt on the turn or river. Useful as the argument to rank or suit operators.

select count(suit(riverCard) = 's')
from   hero='AsKs', villain='*', board='2s3h7d', game='holdem'

Combining

Board functions compose naturally with aggregates and equity. For example, “what is hero’s equity on flushing rivers?” can be written as a riverEquity aggregated under a filter on flushingBoard(river).

Rank Utilities

Rank utilities let you reason about which ranks (2-A) appear in a hand or on the board.

Set-returning

FunctionMeaning
boardRanks(street)Ranks on the board at the given street
handRanks(player)Ranks in a player’s hole cards
duplicatedBoardRanks(street)Ranks appearing more than once on the board
duplicatedHandRanks(player)Ranks appearing more than once in a hand
intersectingHandRanks(player, street)Hand ranks that also appear on the board
nonIntersectingHandRanks(player, street)Hand ranks that do not appear on the board

Scalar

FunctionMeaning
maxRank(ranks)Highest rank in the set
minRank(ranks)Lowest rank in the set
nthRank(ranks, n)The n-th highest rank (1-indexed)
rankCount(ranks)Cardinality of the rank set
handBoardIntersections(player, street)How many hand ranks appear on the board

Predicate

FunctionMeaning
hasTopBoardRank(player, street)Player has at least one card matching the top board rank
hasSecondBoardRank(player, street)Player has at least one card matching the second-highest board rank

Example

Frequency hero flops top pair:

select count(hasTopBoardRank(hero, flop)) as pct_toppair
from   hero='AxKy', villain='*', board='', game='holdem'

Outs

Outs functions count how many cards in the remaining deck would improve a player’s hand to a specific target.

nutHi(player, street)

The number of unseen cards that would give player the nuts on the next street.

select avg(nutHi(hero, turn))
from   hero='AhKh', villain='*', board='Qh7h2c', game='holdem'

nutHiForHandType(player, street, handType)

Same as nutHi, but restricted to a specific made-hand target (e.g. “nut flush”, “nut straight”). Useful when you care about a particular draw category.

Notes

  • “Unseen” means: not in hero, villain, or the known board.
  • For multi-villain spots, unseen still excludes every declared player’s holding.
  • Outs are computed on the exact game being played, so short-deck and Omaha use their respective deck and hand rules.

Preflop Equity vs a Range

A common analysis is “how does my hand fare against a given range of opponent hands, before any community cards are dealt?”

Suited AK vs Top 5% of Hands

A 5% opener might be QQ+, AwQw+, AxKy. Let’s measure suited AK’s equity against that range preflop.

select equity
from   hero='AhKh',
       villain='QQ+, AwQw+, AxKy',
       board='',
       game='holdem'

Run it:

opql --run "select equity from hero='AhKh', villain='QQ+, AwQw+, AxKy', board='', game='holdem'"

Expect an equity in the mid-to-high 40s — AK flips with a tight range.

Sweeping a Range for Hero

You can also give hero a range to see a range-vs-range heatmap figure:

select equity
from   hero='99+, AwJw+',
       villain='QQ+, AxKy',
       board='',
       game='holdem'

The returned number is the combo-weighted average of hero’s range equity against villain’s range.

Narrowing Further

Add a board to see how equity shifts postflop — see the next tutorial, Postflop Analysis.

Postflop Analysis

Once a flop, turn, or river is on the table you can ask more specific questions — frequencies of made hands, equity by street, and so on.

Set on the Flop

How often does hero flop a set with a pocket pair?

select count(flopHandCategory(hero) = 'set') as pct_set
from   hero='TT', villain='*', board='', game='holdem'

board='' means we sample every possible flop. count(predicate) returns the probability the predicate holds.

Equity Given a Draw

Hero has a flush draw on the flop; what’s the showdown equity?

select equity
from   hero='AhKh', villain='AA-JJ', board='Qh7h2c', game='holdem'

Turn Improvement

Conditional probability: given hero holds overcards on the flop, how often does the turn bring a pair?

select count(hasTopBoardRank(hero, turn)) as pct_pair_turn
from   hero='AxKy', villain='*', board='8s5d2c', game='holdem'

Combining Filters

Combine board texture with hand categories — for example, overpair equity on non-paired boards:

select avg(riverEquity)
from   hero='QQ', villain='*', board='Jc7d2s', game='holdem'

From here, add count(overpair(hero)) or count(pairedBoard(river)) selectors to explore the distribution.

Board Texture Studies

This tutorial shows how to measure the distribution of board textures a hand encounters.

Suit Profile on the River

Starting from a two-tone flop, how many suits appear on the river on average?

select avg(boardSuitCount(river))
from   hero='As9s', villain='*', board='2s3sJh', game='holdem'

With two spades on the flop, the river board often stays three-suited, sometimes narrows to two.

Frequency of Monotone Rivers

How often does the river complete a monotone (single-suit) board?

select count(monotoneBoard(river)) as pct_monotone_river
from   hero='*', villain='*', board='', game='holdem'

Paired vs Unpaired

Pair-frequency on the turn:

select count(pairedBoard(turn)) as pct_paired_turn
from   hero='*', villain='*', board='', game='holdem'

Comparing Streets

You can ask multiple questions in one query:

select count(pairedBoard(flop)) as flop_paired,
       count(pairedBoard(turn)) as turn_paired,
       count(pairedBoard(river)) as river_paired
from   hero='*', villain='*', board='', game='holdem'

Each selector produces an independent probability, and together they show how pairing accumulates across streets.

Library Usage

Open PQL’s runner can be embedded in a Rust program to evaluate PQL strings without shelling out to the CLI.

Add the Dependency

[dependencies]
openpql-runner = "0.1.0"

Run a Query

use std::io;
use openpql_runner::PQLRunner;

fn main() -> io::Result<()> {
    let query = "select equity from hero='AhKh', villain='QQ+', \
                 board='Ah9s2c', game='holdem'";

    PQLRunner::run(query, &mut io::stdout(), &mut io::stderr())
}

PQLRunner::run parses, compiles, and evaluates the query, streaming a human-readable report to the provided writers. Use PQLRunner::try_run_stmt if you want to handle the structured output yourself.

Parsing Only

The parser crates can be used independently:

use openpql_pql_parser::parse_pql;

let stmts = parse_pql(
    "select equity from hero='AA', villain='KK', board='AhKh2c', game='holdem'"
)?;

Use this if you want to lint PQL strings in an editor, rewrite them, or generate queries programmatically.

Range Parsing

openpql-range-parser exposes a parser for range strings like QQ+, AwKw, 77-55. Useful for validating user input before passing it into a PQL query.

API Docs

Auto-generated API documentation lives at API Docs.

API Docs

Rustdoc-generated reference documentation for the workspace crates is built separately and published alongside this book.

  • openpql-runner — main library and CLI entry points
  • openpql-prelude — core card and hand types
  • openpql-pql-parser — PQL grammar and AST
  • openpql-range-parser — range-notation parser

Building Locally

cargo doc --no-deps --workspace --open

This generates HTML under target/doc/. Open target/doc/openpql_runner/index.html to explore the runner’s public API.

Why Separate?

This book is written for humans reading top-to-bottom, while rustdoc is a reference derived from the source. Both are useful; when they disagree, rustdoc is authoritative because it’s regenerated from the code on every commit.