A stick table is HAProxy’s in-process key-value store, indexed by an attribute of the connection (source IP, header value, cookie) and storing one or more counters per key. They are the engine behind sticky sessions, rate limiting, abuse detection, and stateful ACLs. This article covers the table model, the sizing math, the data types, and the runtime inspection patterns we use to debug them.
How to verify
For any backend or frontend that defines a stick table, the runtime API exposes its current contents:
echo "show table be_app" | sudo socat /run/haproxy/admin.sock -
echo "show table be_app key 192.0.2.10" | sudo socat /run/haproxy/admin.sock -
echo "show table be_app data.http_req_rate gt 10" | sudo socat /run/haproxy/admin.sock -
echo "show table" | sudo socat /run/haproxy/admin.sock -
show table (no args) lists every table in the running config with its size, used entries, and configured types. show table <name> dumps the entries. Filtering by data.X gt N returns only keys exceeding a threshold — useful for finding the heavy hitters.
What’s happening
A stick-table declaration looks like:
stick-table type <T> size <N> expire <D> [store <fields>] [peers <name>] [nopurge]
type— what the key is.ip(IPv4),ipv6,integer,string len <n>(variable-length string with max length),binary len <n>. Keys of different types cannot coexist in one table.size— maximum number of entries. The table is preallocated; HAProxy reserves memory up front based on type + data fields + size.expire— how long an entry survives without updates before eviction. After the last access +expire, the entry is dropped on the next sweep.store— comma-separated data fields. Each adds memory per entry:gpc0(general-purpose counter),gpc0_rate(window),conn_cnt,conn_cur,conn_rate(window),sess_cnt,sess_rate(window),http_req_cnt,http_req_rate(window),http_err_cnt,http_err_rate(window),bytes_in_cnt,bytes_out_cnt,bytes_in_rate(window),bytes_out_rate(window). Default if you store nothing is justserver_idfor sticky sessions.
Sizing math: a table with type ip size 100k and three rate counters consumes roughly: 100,000 entries × (key + per-counter overhead). For IPv4 keys with three rate counters, count on ~80-100 bytes per entry — ~10 MB for size 100k. For string keys, add the len value to that per-entry cost.
The peers clause is what makes tables survive reloads and replicate across instances — see HAProxy peer protocol. Without peers, the table is per-process and per-reload.
The procedure
-
Define a table at the right scope. Tables live in a
frontend,backend, orpeerssection. You can declare a table in a frontend and reference it from another frontend or backend by name:backend st_global stick-table type ip size 200k expire 1h store http_req_rate(10s),conn_rate(10s) frontend fe_http bind *:80 http-request track-sc0 src table st_global default_backend be_appThe
track-sc0 ... table st_globalis the cross-reference. This lets multiple frontends share one table. -
Increment counters. Counter types divide into two families:
- Cumulative:
_cnt(lifetime count since entry created). Reset only when the entry is evicted. - Rate:
_rate(window)(sliding window count). The window argument is required; e.g.,http_req_rate(10s)counts over a rolling 10-second window.
The
track-sc0(andsc1,sc2) directive ties an incoming connection or request to a key in the table, auto-incrementing the rate counters as requests flow. - Cumulative:
-
Read counters in ACLs. The fetches
sc_http_req_rate(N),sc_conn_cnt(N), etc., read the counter for tracking slot N (sc0,sc1,sc2):frontend fe_http bind *:80 stick-table type ip size 100k expire 30m store http_req_rate(10s) http-request track-sc0 src acl high_rate sc_http_req_rate(0) gt 100 http-request deny status 429 if high_rate default_backend be_app -
General-purpose counters.
gpc0andgpc1are integer counters you increment from ACLs. Use them for arbitrary tagging:frontend fe_http stick-table type ip size 100k expire 1h store gpc0,gpc0_rate(1h) http-request track-sc0 src http-request sc-inc-gpc0(0) if { path /login } { method POST } acl too_many_logins sc_gpc0_rate(0) gt 20 http-request deny status 429 if too_many_logins -
Inspect at runtime. The flat
show tableis your debugging tool:echo "show table be_app" | sudo socat /run/haproxy/admin.sock - | head -20 echo "show table be_app key 192.0.2.10" | sudo socat /run/haproxy/admin.sock - echo "show table be_app data.gpc0 gt 5" | sudo socat /run/haproxy/admin.sock - -
Manipulate tables via runtime. Set a value, clear a key, clear the whole table:
echo "set table be_app key 192.0.2.10 conn_cnt 0" | sudo socat /run/haproxy/admin.sock - echo "clear table be_app key 192.0.2.10" | sudo socat /run/haproxy/admin.sock - echo "clear table be_app" | sudo socat /run/haproxy/admin.sock - -
Persist via peers. Add a
peerssection and reference it. The table is pushed to peers continually, so on reload or instance loss the state is rebuilt:peers haproxy-peers peer hap1 10.0.10.5:1024 peer hap2 10.0.10.6:1024 backend st_shared stick-table type ip size 200k expire 1h store http_req_rate(10s) peers haproxy-peers
Common pitfalls
- A table with
sizetoo small silently evicts entries even beforeexpire. Under load, the oldest entries vanish to make room for new ones — your rate limit becomes effectively useless. Size for peak concurrent unique keys + headroom. - The string key
type string len 64reserves 64 bytes regardless of key length. Long keys are truncated. Chooselento fit your largest realistic key. expire 30son a rate counter resets the entry quickly. If your window ishttp_req_rate(10s), an idle 30 seconds wipes the history; the next request from that key starts at 0. Matchexpireto the longest rate window you care about.- Tracking with
track-sc0in two different frontends withouttableargument creates separate tables, one per section. Usetable <name>to share. set table keyfor a counter that does not exist in thestoreclause silently does nothing. Confirm the store clause includes the counter you are trying to set.
Stack Harbor uses stick tables for rate limits, abuse detection, and per-tenant quotas in HAProxy. The tables are sized from baseline traffic measurements, persisted via peers, and inspected during incident triage with a small wrapper around show table. Documentation of every table — what it stores, why, what reads it — lives in the operations runbook. This is part of how we run Managed Operations for clients with public APIs.