aiogram callback data
callback_data is Telegram's 1–64 byte payload attached to an inline button. Treat it
as routing data, not storage. CallbackData gives you a typed factory with packing,
parsing, size checking, and a dispatch-ready filter.
when to use this skill
- any inline button that must be identified when pressed
- replacing string concatenation or
json.dumpsincallback_data - payloads that exceed 64 bytes
- routing several button kinds to different handlers
- callbacks arriving from buttons an older deployment rendered
defining a factory
from aiogram.filters.callback_data import CallbackData
class Product(CallbackData, prefix="prod"):
action: str
product_id: int
assert Product(action="buy", product_id=42).pack() == "prod:buy:42"
assert Product.unpack("prod:buy:42") == Product(action="buy", product_id=42)
prefix=is required. Omitting it raisesValueError: prefix required, usage example: ...at class-definition time.sep=":"is the default separator; override withsep="|"if your values legitimately contain colons. The separator may not appear inside the prefix.- Fields are ordinary pydantic fields, so defaults,
Optional, and validation all work.
using it
from aiogram import F, Router
from aiogram.filters.callback_data import CallbackData
from aiogram.types import CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
router = Router(name="products")
class Product(CallbackData, prefix="prod"):
action: str
product_id: int
def keyboard(product_id: int) -> InlineKeyboardBuilder:
builder = InlineKeyboardBuilder()
builder.button(text="Buy", callback_data=Product(action="buy", product_id=product_id))
builder.button(text="Info", callback_data=Product(action="info", product_id=product_id))
builder.adjust(2)
return builder
@router.callback_query(Product.filter(F.action == "buy"))
async def buy(callback: CallbackQuery, callback_data: Product) -> None:
"""The filter parses the payload and injects it as `callback_data`."""
await callback.answer(f"buying {callback_data.product_id}")
@router.callback_query(Product.filter())
async def other_product_actions(callback: CallbackQuery, callback_data: Product) -> None:
await callback.answer(callback_data.action)
builder.button(callback_data=...)accepts aCallbackDatainstance directly and packs it for you — no manual.pack().Product.filter()matches any payload with that prefix.Product.filter(F.action == "buy")narrows with a magic filter over the parsed instance.- The narrow handler must be registered before the broad one, as always.
what can be packed
Verified from CallbackData._encode_value. Packing support alone does not guarantee
round-trip validation: use IntEnum for integer enum values, and str, Enum for strings.
A short constant callback string is fine; use a factory for structured fields:
| Type | Encoded as |
|---|---|
None |
empty string |
Enum |
str(value.value) |
UUID |
value.hex (32 chars) |
bool |
"1" / "0" |
int, str, float, Decimal, Fraction |
str(value) |
| anything else | ValueError |
from enum import Enum
from uuid import UUID
from aiogram.filters.callback_data import CallbackData
class Action(str, Enum):
approve = "a"
reject = "r"
class Review(CallbackData, prefix="rv"):
action: Action
item: UUID
silent: bool
packed = Review(
action=Action.approve,
item=UUID("00000000-0000-0000-0000-00000000002a"),
silent=True,
).pack()
assert packed == "rv:a:0000000000000000000000000000002a:1"
A short Enum value is the cheapest way to keep an action name to one byte.
Lists, dicts, dataclasses, and model instances raise ValueError. That restriction is the
point: if it does not fit in a flat 64-byte line, it belongs server-side.
the 64-byte budget
pack() raises when the encoded string exceeds MAX_CALLBACK_LENGTH = 64 bytes:
from aiogram.filters.callback_data import CallbackData
from aiogram.filters.callback_data import MAX_CALLBACK_LENGTH
class TooBig(CallbackData, prefix="big"):
note: str
assert MAX_CALLBACK_LENGTH == 64
try:
TooBig(note="x" * 100).pack()
except ValueError as error:
assert "too long" in str(error)
else: # pragma: no cover
raise AssertionError("expected ValueError")
Bytes, not characters. Cyrillic costs 2 bytes per character and emoji up to 4, so a 30-character Cyrillic label typically costs 60 bytes before the prefix and separators; measure the complete packed payload rather than the visible label.
Ways to stay inside the budget, best first:
- Store the context server-side and put an id on the button.
- Use a short
Enumfor the action instead of a word. - Shorten the prefix — it is repeated on every button.
- Use a compact id (an integer, or a
UUID— 32 hex chars — rather than a formatted string).
separator collisions
A value containing the separator raises on pack():
from aiogram.filters.callback_data import CallbackData
class Search(CallbackData, prefix="s", sep="|"):
query: str
assert Search(query="a:b").pack() == "s|a:b"
try:
Search(query="a|b").pack()
except ValueError as error:
assert "Separator symbol" in str(error)
else: # pragma: no cover
raise AssertionError("expected ValueError")
Never put free-form user text in a payload. If you must, choose a separator the text cannot contain, and validate before packing.
stale and hostile payloads
After a deploy, users still have buttons from the previous version on screen. unpack
raises rather than returning garbage:
- wrong prefix →
ValueError: Bad prefix (...) - wrong field count →
TypeError: ... takes N arguments but M were given - unparseable field → pydantic
ValidationError
The .filter() used in a handler catches these and simply does not match, so a stale
payload falls through to whatever comes next. Give it somewhere sane to land:
from aiogram import Router
from aiogram.types import CallbackQuery
router = Router(name="stale") # include LAST
@router.callback_query()
async def unknown_callback(callback: CallbackQuery) -> None:
"""Catch-all so obsolete buttons do not leave a spinner on the client."""
await callback.answer("This button has expired.", show_alert=True)
Two more rules for the same class of problem:
- Re-check authorisation and existence in the handler. The payload says what the user
clicked, not what they are allowed to do. A
product_idin a button is user-controlled data — anyone can send an arbitrarycallback_datastring. - Make mutating actions idempotent. Users double-tap, and Telegram redelivers. A "confirm payment" button pressed twice must not charge twice.
changing a factory safely
Adding, removing, or reordering fields changes the wire format and breaks every button already on a user's screen. When you must change one:
- bump the prefix (
prod→prod2) so old payloads clearly mismatch rather than mis-parse, and keep the catch-all handler above to explain the expiry; or - add the new field with a default and accept that old buttons fail the field-count check.
Field order matters — unpack zips positionally against model_fields. Reordering
fields silently reinterprets old payloads if the count happens to match, which is worse
than an error. Do not reorder.
checklist
- every inline button uses a
CallbackDatafactory, never a hand-built string - prefixes are short and unique across the codebase
- actions are short
Enumvalues - no free-form user text in a payload
- packed size verified for the worst case, counted in bytes
- narrow
.filter(F...)handlers registered before broad.filter()ones - a catch-all
@router.callback_query()answers unknown payloads - authorisation and entity existence re-checked inside the handler
- mutating handlers safe against repeated presses
see also
aiogram-keyboards— building the buttons these payloads ride onaiogram-routing— handler orderaiogram-filters— magic filters used in.filter(...)skills/using-aiogram/references/telegram-quirks.md— callback expiry and spinner behaviour