Telegram MarkdownV2 Escaping: Why Posts Fail Silently (and the Fix)

Telegram MarkdownV2 Escaping: Why Posts Fail Silently (and the Fix)
TL;DR. Telegram's MarkdownV2 parse mode reserves 18 characters that must be backslash-escaped in normal text —
_ * [ ] ( ) ~`> # + - = | { } . !— plus tighter rules inside code blocks and inline-link URLs. Miss one andsendMessagereturns HTTP 400 withok: false; in production that error gets caught by a wrapper, logged to a place no one reads, and the channel just goes quiet. This guide gives you the full table, a tested Python + JavaScript escape function, the four edge cases that break naive escapers, and a fixture you can run against your own pipeline before the next deploy.
Prerequisites
Photo by Markus Spiske on Pexels
Before you ship a fix:
- A Telegram bot and a chat where it can post. Created via @BotFather, with the bot promoted to admin in any test channel so
sendMessagedoes not returnForbidden. - A working
sendMessagecall. Either via raw HTTP (curl -X POST), the official Bot API, or a wrapper likepython-telegram-bot,aiogram,telegraf, orgrammY. - Visibility into the API response. If your code path catches
Exceptionand never inspectsresponse.json()['ok']or the wrapper'sBadRequest/TelegramBadRequestexception type, fix that first — every step below assumes you can see when a send fails. - A scratch chat ID in
BOT_TEST_CHAT_ID. A private channel or a saved-messages dialog you control. The fixture in Step 5 hammers it with edge cases; do not run that against a real subscriber channel.
If you only have one or two send sites in a small script you can probably swap in the helper from Step 2 and move on. Pipelines with multiple producers (cron jobs, Celery tasks, RSS workers, AI rewrite layers) need every step.
Step 1 — Know the 18 reserved characters (and where each rule applies)
The MarkdownV2 spec reserves three different character sets depending on where the character sits:
| Context | Characters that must be escaped with \ | Notes |
|---|---|---|
| Normal text | _ * [ ] ( ) ~ ` > # + - = | { } . ! (18 total) | Includes the dot and exclamation mark — the two most-missed |
Inside pre and code blocks | ` and \ (2 total) | All other reserved chars are literal |
Inside the URL part of [text](url) and [text](tg://user?id=…) | ) and \ (2 total) | The closing paren and the escape character itself |
Three pitfalls jump out from that table:
- The dot and exclamation mark are reserved. They almost never appear in older Markdown specs, so a naive
re.escapewhitelist forgets them. URLs ending in a TLD (.com,.io) and excited copy (Sale!) trip this every release. - Inside fenced code blocks the rules invert.
*emphasis*is literal — but a stray`ends the block early. If you embed user input inside a code block, escape`and\only, do not escape the rest. - Inside inline-link URLs you escape
)not(. Wikipedia article URLs (https://en.wikipedia.org/wiki/Telegram_(software)) ship a literal)mid-path that closes the markdown link prematurely. Escape it as\)inside the parens, leave the(alone.
The full list comes straight from the Bot API spec. Reproducing it verbatim:
In all other places characters
_,*,[,],(,),~,`,>,#,+,-,=,|,{,},.,!must be escaped with the preceding character\. (core.telegram.org/bots/api#markdownv2-style)
If you parsed that as "use re.escape" you parsed it wrong — re.escape escapes far more characters and produces output that Telegram still rejects, just on different grounds. Use the explicit set; never derive it from a regex stdlib helper.
Step 2 — Build a safe escape function in Python and JS
Photo by Christina Morillo on Pexels
The function below covers the normal-text case. It is intentionally boring — every line maps directly to a sentence in the spec. Save it once, reuse everywhere a string crosses the network to Telegram.
import re
# Per https://core.telegram.org/bots/api#markdownv2-style
_MD2_RESERVED = r"_*[]()~`>#+-=|{}.!"
_MD2_RE = re.compile(f"([{re.escape(_MD2_RESERVED)}])")
def escape_markdown_v2(text: str) -> str:
"""Escape every MarkdownV2 reserved character in `text`.
Use only on plain user-supplied text segments. Do NOT call this on a
string that already contains intentional MarkdownV2 markup (for example
a hand-formatted *bold* word) — escape the segments first, then
concatenate the markup wrappers around them.
"""
return _MD2_RE.sub(r"\\\1", text)
Before-and-after on a real RSS title:
>>> raw = "Postgres 18.0 ships skip-scan indexes (50% smaller plans!)"
>>> escape_markdown_v2(raw)
'Postgres 18\\.0 ships skip\\-scan indexes \\(50% smaller plans\\!\\)'
Note the % is left alone — it is not reserved in MarkdownV2. The two parens and the ! and the . and the - are all escaped. Send the escaped string with parse_mode="MarkdownV2" and you get a clean rendered post.
JavaScript equivalent (Node, Deno, browser, anywhere):
const MD2_RESERVED = /[_*[\]()~`>#+\-=|{}.!]/g;
function escapeMarkdownV2(text) {
return text.replace(MD2_RESERVED, "\\$&");
}
// Usage:
const raw = "Sale ends Friday — 30% off!";
const safe = escapeMarkdownV2(raw);
// → "Sale ends Friday — 30% off\\!" — note the em-dash is fine, only ! reserved
A few things worth nailing down before you ship the helper:
- Escape exactly once. Wrap your final body once, never call the helper twice on the same string. Double-escaping renders backslashes literally in the channel, which looks more amateurish than the original 400.
- Apply per-segment, not per-message. If you want a real bold word, escape the user-controlled middle of
*Hello {name}!*(the{name}!part) but leave the surrounding*…*alone. The wrapper-then-segment pattern is the only one the spec really supports. - Treat the escape function as a single source of truth. Every send site imports the same helper. Inline copy-paste of the regex in 12 different places is how 11 of them eventually drift.
The wrapper libraries do offer their own helpers — telegram.helpers.escape_markdown(text, version=2) in python-telegram-bot, markdownv2.escape in aiogram, Markup.escape in telegraf. Use the library helper if you can, but verify with the table from Step 1: a couple of older library versions miss = or > and ship subtly broken text in production.
Step 3 — Handle the four edge cases that break naive escapers
The 90% case is the helper above. The 10% that bites in production is everything that does not look like normal prose. Cover all four before you call the integration done.
-
URL inside
[text](url)parens. The spec narrows the escape set inside link URLs to)and\. If you run the full normal-text helper over a URL you will producehttps://example\.com/wiki/Telegram\_\(software\), which Telegram rejects withBad Request: can't parse entities: Character '\' is reserved and must be escaped with the preceding '\'. Do this instead:_MD2_LINK_RE = re.compile(r"([\\)])") def escape_markdown_v2_link(url: str) -> str: """Escape a URL for the (...) part of an inline link.""" return _MD2_LINK_RE.sub(r"\\\1", url) def link(text: str, url: str) -> str: return f"[{escape_markdown_v2(text)}]({escape_markdown_v2_link(url)})"Now
link("Telegram (software)", "https://en.wikipedia.org/wiki/Telegram_(software)")produces a working MarkdownV2 link, with the title's parens escaped and the URL's closing paren escaped only. -
preandcodeblocks invert the rule. Inside a fencedcodeblock you escape only`and\; everything else is literal. The same goes for inline`code`. If you reuse the normal-text helper on a code snippet, the user seesdef\ foo\(\)with backslashes everywhere._MD2_CODE_RE = re.compile(r"([\\`])") def escape_markdown_v2_code(code: str) -> str: """Escape a string for inclusion in `pre` or `code` blocks.""" return _MD2_CODE_RE.sub(r"\\\1", code)Wrap the result with single backticks for inline code or triple backticks for a block.
-
Pre-escaped intentional markup. When you want a real bold word inside otherwise-plain user text, escape the segment, then add the markup.
f"*{escape_markdown_v2(headline)}*"is correct; callingescape_markdown_v2("*headline*")gives you\*headline\*— literal asterisks rendered in the channel. -
Entity boundaries inside formatted text. Telegram parses MarkdownV2 left-to-right and a single unmatched
_or*corrupts everything that follows. If user input contains an asterisk and you forget to escape it, the parser sees a stray italic-bold marker and bails on the whole message. The cure is the same — escape early — but the failure mode is worth recognizing in the wild because the error message readsCan't find end of italic entityor similar misleading text. Telegram is not telling you what is wrong; it is telling you what the parser was doing when it gave up. The real fix is upstream.
If you want the formal grammar behind these rules, the Bot API formatting options section and the API entities documentation cover the exact regions and per-region escape sets.
Step 4 — Detect silent failures by always parsing the response
Photo by Anastasiya Badun on Pexels
The "silent" in silent failures is a misnomer. Telegram is not silent — it returns HTTP 400 with a precise ok: false envelope:
{
"ok": false,
"error_code": 400,
"description": "Bad Request: can't parse entities: Character '!' is reserved and must be escaped with the preceding '\\'"
}
The silence is on your side. Three patterns swallow this in production:
- Library exceptions caught and dropped. A worker that wraps
await bot.send_message(...)intry: ... except Exception: log.error(...); returnlooks fine in code review and burns hours later. Catch the specificBadRequest/TelegramBadRequestand re-raise after logging structured fields (chat id, the offending substring, the parser's error suffix). - Direct API calls without an
okcheck. Acurlorrequests.posttoapi.telegram.orgreturns 400 with a JSON body. If your code ignores bothresponse.status_codeandresponse.json()["ok"]because "the request was sent", the failure becomes invisible. - Queue ack-on-handle. Celery, RQ, and Sidekiq workers that ack the job before checking the bot response will silently drop the failure into a DLQ — or worse, no DLQ at all. Move the ack after a successful Telegram response, not after the function returns.
Pattern that works:
import httpx
async def send_md2(chat_id: int, text: str, *, token: str) -> int:
"""Returns the Telegram message id, or raises with structured detail."""
payload = {
"chat_id": chat_id,
"text": escape_markdown_v2(text),
"parse_mode": "MarkdownV2",
}
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json=payload,
)
body = r.json()
if not body.get("ok"):
raise RuntimeError(
f"telegram sendMessage failed: "
f"http={r.status_code} code={body.get('error_code')} "
f"desc={body.get('description')!r} preview={text[:80]!r}"
)
return body["result"]["message_id"]
Three properties matter:
- It always inspects
body["ok"]. Never trusts transport status alone. - It includes a
previewof the offending text in the error so the on-call engineer can spot the bad character without re-running the failing job. - It propagates the failure. The caller decides whether to retry, dead-letter, or alert. Silence is no longer the default.
Pair this with an assert ok is True in your unit tests and you will catch escaping regressions on git push, not on the next channel-quiet incident. For more on production-grade Bot API error handling — including RetryAfter, the 30 msg/sec global cap, and per-chat 1 msg/sec floor — see the Telegram Bot API rate limits guide.
Step 5 — Run a fixture against your pipeline before shipping
Hand-testing escape edge cases is how four-in-a-row regressions ship. Bake the awkward strings into a test that posts to a scratch chat and asserts every message lands.
import os, asyncio
FIXTURES = [
("plain text", "Hello world"),
("trailing exclamation", "Big sale this Friday!"),
("dotted version", "Postgres 18.0 ships next week"),
("dashed list", "Three picks: - milk - bread - eggs"),
("paren in copy", "Telegram (the app) added scheduled posts"),
("paren in URL", "Read https://en.wikipedia.org/wiki/Telegram_(software)"),
("code-fence inversion", "Use ```re.compile(r'\\d+')``` to match digits"),
("contraction (apostrophe)", "It's the dot that gets you, not the apostrophe"),
("inline link with parens", "[Telegram (software)](https://en.wikipedia.org/wiki/Telegram_(software))"),
("emoji", "Shipping 🚀 today!"),
("plus-and-equals", "C++ vs C#=Pascal? Discuss"),
("braces and pipes", "Use {{template}} | output | filter"),
]
async def main() -> None:
chat_id = int(os.environ["BOT_TEST_CHAT_ID"])
token = os.environ["BOT_TOKEN"]
for name, text in FIXTURES:
try:
mid = await send_md2(chat_id, text, token=token)
print(f"OK {name!r:35} → message_id={mid}")
except Exception as exc:
print(f"FAIL {name!r:35} → {exc}")
asyncio.run(main())
Run it once on every wrapper-library upgrade and on every refactor of your escape helper. Twelve seconds of CI is cheaper than one missed launch announcement.
For pipelines that ship many messages a minute — RSS forwarders, AI digest bots, bulk-reply workers — the same fixture is also a smoke test for rate limiting. See the bulk Telegram messaging guide for the queue and retry patterns that keep escaping bugs from compounding into rate-limit cascades.
Common mistakes
- Using
re.escapeinstead of the explicit table.re.escapeescapes characters that MarkdownV2 does not reserve (@,:,/, etc.) and produces strings Telegram rejects with a different but still confusing 400. - Catching
Exceptionaround the send call. Hides every kind of failure — escaping, rate limit, network — behind a single log line. Catch the wrapper's specific exception type, log structured detail, re-raise. - Calling the escape helper twice. Each call doubles the backslashes. Subscribers see literal
\.and\!in the channel and the on-call engineer scratches their head. - Escaping a URL inside
[text](url)with the normal-text helper. Produceshttps:\/\/example\.comand a 400. Escape link URLs with the()-aware helper from Step 3. - Forgetting that fenced code blocks invert the rules. Re-escaping every reserved char inside a code snippet ships visible backslashes everywhere. Inside
`and```only`and\are special. - Trusting transport status (200) without checking
body["ok"]. A 400 withok: falselooks like a 200 to a script that only inspectsresponse.status_codefrom the wrapper layer (some libraries swallow the 400 and surface it via a thrown exception instead).
Related reading
- Telegram Bot API Rate Limits at Scale: What Breaks (and How to Stay Under) — the companion article on the other class of silent Bot API failure: rate-limit
RetryAfterresponses that workers ack before reading. - Bulk Telegram Messaging Automation: A Power-User Guide — operational patterns (queues, retries, DLQs) that catch escape regressions before they cascade into channel-quiet incidents.
- Telegram RSS Feed Automation Setup: A 2026 How-To — RSS items are the highest-density source of unescaped reserved characters; the escape helper from Step 2 is exactly what slots between feed parser and
sendMessage. - Telegram Auto Post Without the Forwarded Tag: A How-To — when you ship native posts (no forward header), formatting quality is the only signal a post is yours, so MarkdownV2 cannot be sloppy.
FAQ
Which characters does Telegram MarkdownV2 require to be escaped?
Eighteen characters in normal text: _, *, [, ], (, ), ~, `, >, #, +, -, =, |, {, }, ., !. Inside `` and ``` code blocks only ` and \ need escaping; inside the URL part of an inline link [text](url) only ) and \ need escaping. The list is published verbatim in the Bot API formatting documentation.
Why does my Telegram bot post nothing — no error, no message?
Telegram is returning HTTP 400 ok: false with a precise description field, but your code is swallowing it. Three usual suspects: a try: ... except Exception block that catches the wrapper's BadRequest, a direct requests.post that ignores response.json()["ok"], or a queue worker that acks the job before checking the Telegram response. Add body["ok"] checks and a structured log line and the silence stops.
Can I just use Python's re.escape to escape MarkdownV2?
No. re.escape is built for the regex grammar, not Telegram's MarkdownV2 grammar — it escapes @, :, /, ^, $, and others that MarkdownV2 leaves alone. The re.escape output reaches Telegram with foreign backslashes and triggers a different 400 (Character '\\' is reserved and must be escaped). Use the explicit 18-char regex from Step 2 instead.
How do I escape a URL inside a Telegram MarkdownV2 inline link?
Inside the parentheses of [text](url) only two characters need escaping: ) and \. The text segment outside the parens is normal text and follows the full 18-character rule. A URL like https://en.wikipedia.org/wiki/Telegram_(software) becomes https://en.wikipedia.org/wiki/Telegram_(software\) inside the parens — only the closing paren is escaped, the underscore and the opening paren stay literal.
Do code blocks follow the same MarkdownV2 escaping rules?
No. Inside a `inline` or fenced code block, only the backtick ` and the backslash \ are reserved. Every other character — including all 16 other normal-text reserved chars — is literal. If you reuse the normal-text escape helper inside a code block, subscribers see def\ foo\(\) with literal backslashes and your snippet looks broken.
What's the safest way to send a MarkdownV2 message in production?
Three layers: (1) an escape_markdown_v2(text) helper imported once and applied to every user-supplied segment, (2) an HTTP wrapper that always inspects response.json()["ok"] and raises with the offending preview substring, (3) a fixture suite of awkward strings (dots, exclamations, parens-in-URL, code blocks, emoji) that runs in CI on every escape-helper or wrapper-library change. The Step 5 fixture above is a working starting point.
Bottom line
MarkdownV2 escaping is not subtle — there are 18 characters to escape in normal text, two each inside code blocks and link URLs, and a single body["ok"] check between you and silent failures. Add the helper from Step 2, the response-aware wrapper from Step 4, and the fixture from Step 5 to your pipeline and the next "channel went quiet at 3 a.m." page never lands. If you'd rather skip the worker entirely, Autogram handles the escaping and surfaces every Bot API error in plain English — paste copy, pick a channel, ship.
Image credits
- Hero: Photo by Markus Spiske on Pexels.
- Inline #1: Photo by Christina Morillo on Pexels.
- Inline #2: Photo by Anastasiya Badun on Pexels.
Related Posts

How to Add a Telegram Mini App to Your Channel (No-Code Guide)
Learn how to attach a Telegram Mini App to your channel and start accepting Stars payments — without writing a single line of code. A practical operator guide for 2026.

TON to Fiat for Channel Operators: Fragment Withdrawal Walkthrough
Step-by-step guide to withdrawing Telegram channel revenue — ad TON and Stars — via Fragment, converting on a crypto exchange, and banking the proceeds.

Refunding Telegram Stars: How refundStarPayment Actually Works
When to refund a Telegram Stars charge, how to call refundStarPayment, the no-partial-refund rule, /paysupport handling, and clean audit-log reconciliation.
Subscribe to our newsletter
Get the latest Telegram growth tips, automation strategies, and platform updates delivered to your inbox.
Or follow our Telegram channel