The short answer
- 1Formatting is a separate list of entities: type, offset and length.
- 2Offsets and lengths count UTF-16 units, so emoji take 2 or more.
- 3Bots can use parse_mode or pass entities; escape user input.
In this article
Formatting sits beside the text, not inside it
Bold, italics, links and other formatting in a Telegram message are not stored as ** or tags inside the text. The text is plain characters, and the formatting is attached separately as a list of entities. Each entity has three parts: a type, a starting position (offset) and a length (length).
text: Hello and welcome
entities: [{type: bold, offset: 0, length: 5}]
-> only "Hello" is bold
| Group | Entity types (examples) |
|---|---|
| Text style | Bold, italic, underline, strikethrough, spoiler |
| Code | Inline code, code block (optionally with a language name) |
| Links and people | Text links, user mentions, mentions by name (for people without a username) |
| Other | Custom emoji, quotes, expandable quotes |
Things the server can recognise on its own, such as URLs, hashtags, @usernames and /commands, get entities automatically. Entities can overlap (bold italic, for example), but some types, such as code blocks, cannot contain other formatting.
Positions are counted in UTF-16 units
This is the most common mistake. offset and length are not character counts; they are counted in UTF-16 code units.
| Character | UTF-16 units |
|---|---|
| A Latin letter, or a Korean or Chinese character | 1 |
| Most emoji (characters outside the Basic Multilingual Plane) | 2 |
| Combined emoji such as skin tones or families | The sum of their parts: 4, 7, 11 and more |
If you compute positions in a language that counts characters (code points), such as Python's len(), every formatting range after an emoji shifts by one or two places. Use the length of the string encoded as UTF-16 (bytes divided by two), or the native length in a language that already counts in UTF-16, such as JavaScript.
def u16(s): # length in UTF-16 units
return len(s.encode("utf-16-le")) // 2
Where Markdown and HTML get converted
- User-account API: the app (client) converts what the user typed in Markdown or HTML into entities, and sends the server plain text plus the entity list.
- Bot API: set
parse_modetoMarkdownV2orHTMLand Telegram converts it for you. You can also pass an entity list directly (entities); positions are still in UTF-16 units. See your first Bot API requests.
How the formatting looks to people writing messages is covered in formatting text in Telegram.
FAQ
Why is my Telegram bot's bold text in the wrong place?
Offsets are counted in UTF-16 units. Emoji count as two or more, so counting characters shifts later ranges.
How do I calculate entity offsets in Python?
Use len(text.encode('utf-16-le')) // 2 for lengths instead of len(text).
What happens if user input contains Markdown symbols?
With MarkdownV2 it can break formatting or be rejected. Escape the symbols or send entities directly.
Sources
At a glance
- 1Entities
Plain text plus a list of formatted ranges.
- 2UTF-16
Count encoded length, not characters, or ranges shift.
- 3Bots
MarkdownV2 or HTML via parse_mode, or entities directly.
Updated