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
opqlcommand or embed the runner crate in your code.
Workspace Crates
| Crate | Purpose |
|---|---|
openpql-prelude | Core poker types: cards, hands, evaluators |
openpql-core | Game abstraction and query execution core |
openpql-range-parser | Parser for range notation (AA-TT, AwKw+, …) |
openpql-pql-parser | Parser for PQL syntax |
openpql-runner | Query executor and opql CLI |
openpql-macro | Internal procedural macros |
How to Read This Book
- If you’re new, start with Installation then Your First Query.
- The PQL Language section covers syntax in depth.
- Built-in Functions lists every function available inside
select. - The Tutorials walk through realistic analysis workflows.
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
cargotoolchain
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 selectorhero='AhKh'— hero holds the Ace and King of heartsvillain='QQ+'— villain has any pocket pair QQ or betterboard='Ah9s2c'— the community cards on the flopgame='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:
- Direct values — e.g.
equity, a scalar produced per trial. - Aggregates — e.g.
avg(expr),count(expr), reducing across trials. - Boolean predicates — used inside aggregates to count probabilities.
Bindings (the from clause)
Bindings declare the poker situation to simulate. The most common ones are:
| Binding | Meaning |
|---|---|
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.
| Aggregate | Result |
|---|---|
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
| Notation | Meaning |
|---|---|
AsKh | Exact two cards |
AwKw | Suited AK |
AxKy | Offsuit AK |
AK | Any AK |
TT | Any pocket tens |
* | Any two cards |
Spans
| Notation | Meaning |
|---|---|
QQ+ | Pocket pairs QQ or better |
88-55 | Pocket pairs from 88 down to 55 |
AwJw+ | Suited aces from AJ up |
KwQw-KwTw | Suited 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.
Related Crate
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
| Street | Board size | Cards sampled each trial |
|---|---|---|
| Preflop | 0 | flop + turn + river |
| Flop | 3 | turn + river |
| Turn | 4 | river |
| River | 5 | none — 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 cardsturn— the fourth cardriver— 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.
| Value | Variant | Hole cards | Deck |
|---|---|---|---|
holdem | Texas Hold’em | 2 | Full 52 |
omaha | Omaha Hi | 4 | Full 52 |
shortdeck | Short-Deck Hold’em | 2 | 36 (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.
| Category | Functions | Page |
|---|---|---|
| Equity | equity, hvhequity, minHvHEquity, riverEquity, fractionalRiverEquity | Equity |
| Hand categories | flopHandCategory, exactFlopHandCategory, minFlopHandCategory, overpair, pocketPair | Hand Categories |
| Board texture | boardSuitCount, flushingBoard, monotoneBoard, pairedBoard, rainbowBoard, twotoneBoard, straightBoard, turnCard, riverCard | Board Texture |
| Rank utilities | boardRanks, handRanks, maxRank, minRank, nthRank, rankCount, hasTopBoardRank, hasSecondBoardRank, handBoardIntersections, and more | Rank Utilities |
| Outs | nutHi, nutHiForHandType | Outs |
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
| Function | Meaning |
|---|---|
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
| Function | Meaning |
|---|---|
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
| Function | Meaning |
|---|---|
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 pointsopenpql-prelude— core card and hand typesopenpql-pql-parser— PQL grammar and ASTopenpql-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.