<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vaibhav Mittal</title>
    <description>The latest articles on DEV Community by Vaibhav Mittal (@vaibhav_mittal_ac22a2c5d6).</description>
    <link>https://dev.to/vaibhav_mittal_ac22a2c5d6</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4034131%2F105ed466-20be-4540-ab79-cb5f8576a2c1.png</url>
      <title>DEV Community: Vaibhav Mittal</title>
      <link>https://dev.to/vaibhav_mittal_ac22a2c5d6</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vaibhav_mittal_ac22a2c5d6"/>
    <language>en</language>
    <item>
      <title>Grammars are written in characters. Models emit tokens.</title>
      <dc:creator>Vaibhav Mittal</dc:creator>
      <pubDate>Mon, 27 Jul 2026 13:44:55 +0000</pubDate>
      <link>https://dev.to/vaibhav_mittal_ac22a2c5d6/grammars-are-written-in-characters-models-emit-tokens-1k07</link>
      <guid>https://dev.to/vaibhav_mittal_ac22a2c5d6/grammars-are-written-in-characters-models-emit-tokens-1k07</guid>
      <description>&lt;p&gt;I am about to spend several weeks building a constrained decoding engine, so I started by reading a paper from 2019.&lt;/p&gt;

&lt;p&gt;Section 2.2 of the GPT-2 paper is about half a page long. It sits between the training dataset section and the model architecture section, and it is easy to skim past. It is also, as far as I can tell, the origin of the single hardest problem in the field I am about to work in.&lt;/p&gt;

&lt;p&gt;Here is what it says, what I verified by running the tokenizer myself, and the thing I found at the end that I did not expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part everybody already knows
&lt;/h2&gt;

&lt;p&gt;A model computes on numbers. Text is characters. Something has to bridge that, and there are three obvious ways to do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Word level.&lt;/strong&gt; Map every word to an id. The vocabulary explodes, because every inflection and typo and compound is a new entry, and anything the model never saw during training becomes &lt;code&gt;&amp;lt;UNK&amp;gt;&lt;/code&gt;. The model goes blind on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Character level.&lt;/strong&gt; Map every character to an id. Nothing is ever out of vocabulary, because you can spell anything. But sequences get roughly five times longer, transformer attention cost scales with the square of sequence length, and you have made the model learn spelling before it can learn meaning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subword level.&lt;/strong&gt; Split into pieces. Common words stay whole, rare words break into fragments the model has seen in other contexts. Short sequences, no out of vocabulary hole.&lt;/p&gt;

&lt;p&gt;Byte Pair Encoding is the subword method that won. It came from a 1994 data compression algorithm and got repurposed for NLP by Sennrich et al. in 2015. The training loop is four lines of pseudocode:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start with a base vocabulary of every individual symbol in your corpus.&lt;/li&gt;
&lt;li&gt;Count every adjacent pair of symbols.&lt;/li&gt;
&lt;li&gt;Merge the most frequent pair into a new symbol and add it to the vocabulary.&lt;/li&gt;
&lt;li&gt;Go back to step 2 until you hit your target size.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is it. If &lt;code&gt;t h&lt;/code&gt; shows up constantly, you get a &lt;code&gt;th&lt;/code&gt; token. Then &lt;code&gt;th e&lt;/code&gt; shows up constantly, so you get &lt;code&gt;the&lt;/code&gt;. Run it 50,000 times and common English words are single tokens while rare ones stay in fragments.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finivgvktq3kncth4c9wv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finivgvktq3kncth4c9wv.png" alt="Diagram comparing Word-Level, Character-Level, and Subword-Level (BPE) tokenization, highlighting OOV issues, sequence lengths, and computational efficiency trade-offs." width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The property worth holding onto: this is greedy, frequency based, and entirely dependent on the corpus. Nothing about the resulting vocabulary is linguistically principled. It is whatever compressed the training data. That is why different tokenizers split the same word differently and why numbers and code often tokenize in ways that look arbitrary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GPT-2 actually contributed
&lt;/h2&gt;

&lt;p&gt;Standard BPE at the time ran over Unicode code points. Unicode has more than 130,000 of them, so your base vocabulary is enormous before you have merged anything, and you still have holes for anything outside your set.&lt;/p&gt;

&lt;p&gt;GPT-2 runs BPE over UTF-8 bytes instead.&lt;/p&gt;

&lt;p&gt;A byte has 256 possible values. That is your entire base vocabulary. And because every possible Unicode string encodes to some sequence of UTF-8 bytes, there is no out of vocabulary case. Not for any language, not for emoji, not for malformed input. Any string at all.&lt;/p&gt;

&lt;p&gt;This is the actual point of byte level BPE and I think it usually gets described wrong. It is not primarily an efficiency win. It is a coverage guarantee bought with a tiny base vocabulary.&lt;/p&gt;

&lt;p&gt;The paper is also honest about what broke when they first tried it. Applying BPE naively to bytes produced bad merges, because the greedy frequency heuristic learned separate tokens for &lt;code&gt;dog&lt;/code&gt;, &lt;code&gt;dog.&lt;/code&gt;, &lt;code&gt;dog!&lt;/code&gt;, and &lt;code&gt;dog?&lt;/code&gt;. Four vocabulary slots for one word. So they blocked merges across character categories: letters do not merge with punctuation.&lt;/p&gt;

&lt;p&gt;You can check that this worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'dog'   -&amp;gt; ['dog']         [9703]
'dog.'  -&amp;gt; ['dog', '.']    [9703, 13]
'dog!'  -&amp;gt; ['dog', '!']    [9703, 0]
'dog?'  -&amp;gt; ['dog', '?']    [9703, 30]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;dog&lt;/code&gt; token, id 9703, in all four. The punctuation is separate every time.&lt;/p&gt;

&lt;p&gt;Then they carved out an exception for spaces, because blocking every cross category merge fragments ordinary text badly. Spaces get attached to the token that follows them, not the one before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'hello world' -&amp;gt; ['hello', 'Ġworld']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Ġ&lt;/code&gt; is how GPT-2's vocabulary renders a leading space. It is a prefix, which means &lt;code&gt;hello&lt;/code&gt; and &lt;code&gt;hello&lt;/code&gt; are two different tokens with two different ids (31373 and 23748). The same five letters, in the same order, are a different symbol to the model depending on whether a space came first.&lt;/p&gt;

&lt;p&gt;Hold that thought.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhk5s57scy89rg0jzbpe1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhk5s57scy89rg0jzbpe1.png" alt="Diagram illustrating the Byte-Level BPE training loop and GPT-2 specific optimizations like character-category merge blocking and whitespace handling." width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade nobody explains cleanly
&lt;/h2&gt;

&lt;p&gt;Vocabulary size is a knob with a real cost on both sides.&lt;/p&gt;

&lt;p&gt;Bigger vocabulary means shorter sequences for the same text, so fewer decode steps to say the same thing. But your embedding matrix and your final softmax layer both scale with vocabulary size, so you pay in parameters and in output projection compute.&lt;/p&gt;

&lt;p&gt;Smaller vocabulary means a cheaper embedding and softmax, but longer sequences. And since decoding is one sequential forward pass per token, and decoding is the memory bound half of inference, more tokens is directly more wall clock time.&lt;/p&gt;

&lt;p&gt;50,257 was GPT-2's answer. It is not a constant of nature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now the part I actually came for
&lt;/h2&gt;

&lt;p&gt;Everything above is background. Here is why I was reading it.&lt;/p&gt;

&lt;p&gt;Constrained decoding is the technique that forces a model's output to conform to a schema. At every generation step, before sampling, you set the logits of every token that would break the schema to negative infinity. After softmax those tokens have probability exactly zero. The model cannot emit them. Validity stops being something you hope for and becomes something structural.&lt;/p&gt;

&lt;p&gt;To do that, you compile the schema into a state machine. At any state you know which characters are legal next, so you know which tokens are legal next, so you know what to mask.&lt;/p&gt;

&lt;p&gt;Except the schema is defined over characters and the model emits tokens, and those two things do not line up.&lt;/p&gt;

&lt;p&gt;I knew that abstractly before I started. What I did not appreciate is how badly they do not line up. So I ran GPT-2's tokenizer over some JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;
&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;        &lt;span class="c1"&gt;# [4895]
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;        &lt;span class="c1"&gt;# [1298]
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;        &lt;span class="c1"&gt;# [20662]
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;},{&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="c1"&gt;# [11919]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{"&lt;/code&gt; is one token. The opening brace and the opening quote of the first key are fused into a single symbol, id 4895.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;":&lt;/code&gt; is one token. The closing quote of a key and the colon that follows it, fused, id 1298.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;"},{"&lt;/code&gt; is one token. Id 11919. Five characters: close a string, close an object, comma, open an object, open a string. That is five separate transitions in any JSON grammar you would write, and the model emits all of them or none of them, as one indivisible unit.&lt;/p&gt;

&lt;p&gt;A full tool call looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'{"location": "Jaipur", "unit": "celsius"}'

['{"', 'location', '":', 'Ġ"', 'Ja', 'ip', 'ur', '",',
 'Ġ"', 'unit', '":', 'Ġ"', 'cel', 's', 'ius', '"}']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixteen tokens. Look at where the boundaries fall. Not one of the structural delimiters sits alone. &lt;code&gt;{"&lt;/code&gt;, &lt;code&gt;":&lt;/code&gt;, &lt;code&gt;",&lt;/code&gt;, &lt;code&gt;Ġ"&lt;/code&gt;, &lt;code&gt;"}&lt;/code&gt;. The grammar's alphabet and the model's alphabet are different alphabets, and the model's is coarser in exactly the places the grammar cares about most.&lt;/p&gt;

&lt;p&gt;Out of 50,257 entries in the vocabulary, 50,001 are longer than a single character. That is 99.5 percent. 311 of them contain at least one of &lt;code&gt;{ } [ ] " : ,&lt;/code&gt; and 92 of those also contain alphanumerics, which means there are almost a hundred single tokens that straddle the line between structure and content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this costs you accuracy
&lt;/h2&gt;

&lt;p&gt;Here is the mechanism, as I currently understand it. I want to be clear that this is the field's leading explanation rather than something settled, and testing it is a large part of what I am about to do.&lt;/p&gt;

&lt;p&gt;Say a model is parsing a receipt and the correct total is &lt;code&gt;0.46&lt;/code&gt;. Left alone, it tokenizes like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'0.46' -&amp;gt; ['0', '.', '46']   [15, 13, 3510]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tokens, and &lt;code&gt;46&lt;/code&gt; is a single one of them, id 3510. That is the path the model has walked thousands of times in training.&lt;/p&gt;

&lt;p&gt;Now constrain it. The grammar is in some state where it expects a number, and depending on how your automaton is built and where the previous token landed you may end up masking in a way that forces the model down a different token path to spell the same string. The output is still valid JSON. It just took a route through the distribution that the model almost never took during training, in this context, and everything it generates after that point is conditioned on a prefix it did not choose.&lt;/p&gt;

&lt;p&gt;BAML published a concrete case of this. On a receipt parsing task, constrained decoding returned &lt;code&gt;1&lt;/code&gt; where the correct value was &lt;code&gt;0.46&lt;/code&gt;. Across the task they measured 91.37 percent accuracy constrained against 93.63 percent with free form parsing.&lt;/p&gt;

&lt;p&gt;The 2 point gap is not the interesting part. The interesting part is that &lt;code&gt;1&lt;/code&gt; is perfectly valid JSON. Your schema validated. Your types checked. Your pipeline reported success. There is no exception, no parse error, no signal anywhere that anything went wrong. The failure is silent by construction, because the entire purpose of the constraint was to make invalid output impossible, and it succeeded.&lt;/p&gt;

&lt;p&gt;At the larger end, Tam et al. measured format restrictions degrading reasoning accuracy on benchmarks by as much as 27 points under strict constraints (EMNLP 2024). That number gets quoted a lot without its conditions attached, so treat it as an upper bound under specific settings rather than a headline, but the direction has since been replicated independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token healing, and why it exists
&lt;/h2&gt;

&lt;p&gt;There is a partial fix for the boundary half of this, and it has been sitting in Guidance for a while.&lt;/p&gt;

&lt;p&gt;When a constraint truncates in the middle of what would naturally have been a single token, you back up to a clean token boundary and re-constrain from there, so the model gets to pick a token that spans the boundary rather than being forced to spell it out piece by piece.&lt;/p&gt;

&lt;p&gt;It is called token healing, and reading GPT-2's section 2.2 is what made me understand why it needs to exist at all. It is not a workaround for a sloppy implementation. It is a direct consequence of a design decision made in 2019 for entirely unrelated reasons: bytes instead of code points, greedy frequency merges, spaces glued to the front of the following word. None of those choices were made with grammars in mind, because grammar constrained generation did not exist yet.&lt;/p&gt;

&lt;p&gt;The XGrammar paper puts the same thing more precisely than I can. Their framing is that tokens are fixed strings which may not correspond to complete semantic units and may split Unicode characters, and that this is the fundamental challenge for structured generation. I found that sentence after I had worked out the problem by hand, which was a useful check that I was looking at the right thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do not know yet
&lt;/h2&gt;

&lt;p&gt;Two things I want to measure rather than assume.&lt;/p&gt;

&lt;p&gt;First, how much of the observed degradation is actually the boundary problem versus the other candidate mechanism, which is that a schema forces the model to commit to an &lt;code&gt;answer&lt;/code&gt; field before the reasoning that would produce the answer has happened. My prior is that the second dominates on reasoning tasks and the first dominates on extraction tasks, but that is a guess and I have not seen it cleanly separated anywhere.&lt;/p&gt;

&lt;p&gt;Second, whether the degradation is size dependent. If small models are hurt more than large ones, or the reverse, the shape of that curve says something about whether this is a capacity problem or a distributional one.&lt;/p&gt;

&lt;p&gt;I am reproducing all of this across four model sizes over the next couple of weeks and publishing the harness with the numbers. If you have run into a case where structured output gave you a wrong but valid answer, I would like to see it. I am collecting them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Everything above runs in about thirty seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pip install tiktoken
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;
&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;toks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode_single_token_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;!r:&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;toks&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dog&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dog.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;hello&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; hello&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;},{&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0.46&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;46&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Jaipur&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;celsius&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GPT-2 paper is &lt;em&gt;Language Models are Unsupervised Multitask Learners&lt;/em&gt;, Radford et al. 2019. Section 2.2 is the one to read, and it is shorter than this post.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>Self-hosting SigNoz: notes from a day of actually running it</title>
      <dc:creator>Vaibhav Mittal</dc:creator>
      <pubDate>Fri, 17 Jul 2026 17:33:33 +0000</pubDate>
      <link>https://dev.to/vaibhav_mittal_ac22a2c5d6/self-hosting-signoz-notes-from-a-day-of-actually-running-it-14ph</link>
      <guid>https://dev.to/vaibhav_mittal_ac22a2c5d6/self-hosting-signoz-notes-from-a-day-of-actually-running-it-14ph</guid>
      <description>&lt;p&gt;Last week I self-hosted SigNoz, wired a small app into it, and spent a day poking at traces, logs, dashboards, and alerts. Most of it worked the way the docs said it would. Two things did not, and one of those turned into the most useful thing I learned all day: my logs and my traces were both arriving in SigNoz, but they didn't know about each other.&lt;/p&gt;

&lt;p&gt;This post is a writeup of the whole thing, including the parts where I got stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app
&lt;/h2&gt;

&lt;p&gt;I needed something real to observe, so I built Shelfie, a Flask + SQLite API for tracking books. It has the usual CRUD routes, plus a &lt;code&gt;/books/&amp;lt;id&amp;gt;/recommendations&lt;/code&gt; endpoint that calls the Open Library API live. I also added two endpoints on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;related-slow&lt;/code&gt; fetches all the book IDs for a subject, then queries each one individually (the classic N+1 pattern)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;related-fast&lt;/code&gt; returns the same data in a single query&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idea was to have a known performance bug in the app and see how obvious SigNoz could make it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying SigNoz
&lt;/h2&gt;

&lt;p&gt;SigNoz now ships an installer called Foundry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://signoz.io/foundry.sh | bash
foundryctl cast &lt;span class="nt"&gt;-f&lt;/span&gt; casting.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;casting.yaml&lt;/code&gt; is seven lines saying "docker compose, please". That generated and started six containers: ClickHouse, a Postgres metastore, the OTel collector, and the main &lt;code&gt;signoz&lt;/code&gt; server.&lt;/p&gt;

&lt;p&gt;Two things tripped me up here. First, the UI is on port 8080 now. A lot of older tutorials say 3301. Second, and this one cost me more time: the collector would not accept any telemetry at first. Its logs showed the opamp connection failing with &lt;code&gt;"cannot create agent without orgId"&lt;/code&gt;. It turns out that until you create the first admin account, there is no organization in the metastore, and the collector has nowhere to attach your data. I registered via the API (&lt;code&gt;POST /api/v1/register&lt;/code&gt;) and the error went away immediately. So if your fresh SigNoz install seems to be dropping everything silently, check whether you ever finished signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instrumenting Flask
&lt;/h2&gt;

&lt;p&gt;Traces needed no code changes at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;opentelemetry-instrument &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--traces_exporter&lt;/span&gt; otlp &lt;span class="nt"&gt;--metrics_exporter&lt;/span&gt; otlp &lt;span class="nt"&gt;--logs_exporter&lt;/span&gt; otlp &lt;span class="se"&gt;\&lt;/span&gt;
  flask run &lt;span class="nt"&gt;--port&lt;/span&gt; 5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added two custom metrics by hand (a counter for books created and a histogram timing the Open Library calls), then ran a small load generator against the app: normal browsing, recommendations, both related endpoints, and a steady trickle of requests for a book ID that doesn't exist, so there would be a real 404 rate.&lt;/p&gt;

&lt;p&gt;One installation problem I would point out : I had pinned &lt;code&gt;opentelemetry-distro==0.48b0&lt;/code&gt;, and &lt;code&gt;opentelemetry-bootstrap&lt;/code&gt; then installed instrumentation packages from 0.65b0. Pip's resolver complained mid-install and the bootstrap failed. Upgrading everything to a matched set (SDK 1.44.0, instrumentation 0.65b0) fixed it. These packages really do need to move together.&lt;/p&gt;

&lt;p&gt;A minute or so after starting the load generator, the Services page showed &lt;code&gt;shelfie&lt;/code&gt; with a P99 of 1749 ms (the live Open Library calls are slow) and a 0.47% error rate from the deliberate 404s:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fguw2jmm8uwwxal2j00wx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fguw2jmm8uwwxal2j00wx.png" alt="SigNoz home showing the shelfie service with live P99, error rate, and ops/sec" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The N+1 in a waterfall
&lt;/h2&gt;

&lt;p&gt;The slowest &lt;code&gt;related-slow&lt;/code&gt; trace had 10 spans and took 74.27 ms: the Flask handler span, a connect, and then a stack of single-row &lt;code&gt;SELECT&lt;/code&gt;s, each with its full SQL in the span attributes. The equivalent &lt;code&gt;related-fast&lt;/code&gt; trace had 5 spans and took 7.94 ms. Same response body, about 9x the latency. You can see the problem in the shape of the waterfall before you read anything:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4s0ydv0aw0azanocnhy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4s0ydv0aw0azanocnhy.png" alt="Trace waterfall of the N+1 endpoint: 74.27ms, 10 spans, repeated SELECTs" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All of those spans came from auto-instrumentation (Flask, SQLAlchemy, requests). I wrote none of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The empty Logs tab
&lt;/h2&gt;

&lt;p&gt;Now the part I actually want to tell you about. I opened one of those traces and clicked the Logs tab in the span details panel, expecting to see the request's log lines. Instead: "No logs found for this trace."&lt;/p&gt;

&lt;p&gt;The logs were in SigNoz. I could query them in the Logs explorer. But their &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;span_id&lt;/code&gt; fields were empty, so nothing linked them to the trace. I checked ClickHouse directly to be sure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;trace_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;span_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;127.0.0.1 - - [17/Jul/2026 13:28:33] "GET /books/subjects/romance/related-slow HTTP/1.1" 200 -&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That log line is Werkzeug's access log, and it explains the whole thing. The access log is written after the request finishes. By that point the span has ended and there is no active trace context left to inject, so the exporter ships the line with no IDs. Everything was "working". The correlation just silently wasn't happening.&lt;/p&gt;

&lt;p&gt;The fix was two small changes. I added a normal &lt;code&gt;logger.info(...)&lt;/code&gt; call inside the recommendations handler, so at least one log line is emitted while the span is still open, and I set &lt;code&gt;OTEL_PYTHON_LOG_CORRELATION=true&lt;/code&gt; so the logging instrumentation stamps the active trace context onto each record. After a restart, the new log line had a real trace_id in ClickHouse, and the same trace's Logs tab in the UI now showed it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx3a31enlkhg1tzs1pcpa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx3a31enlkhg1tzs1pcpa.png" alt="The same trace's Logs tab now showing the correlated application log line" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This trace-to-logs link ended up being my favorite feature in SigNoz. Being able to open a slow request and see only that request's log lines removes the usual step of grepping logs around a timestamp and hoping. But the lesson from the empty tab stands: a connected pipeline is not the same as correlated data. If your framework logs after the span closes, you get nothing, and no error tells you so. You have to open a trace and check.&lt;/p&gt;

&lt;h2&gt;
  
  
  A dashboard and a real alert
&lt;/h2&gt;

&lt;p&gt;I built a small dashboard with two panels: the custom books-created counter, and a spans-per-minute panel filtered to &lt;code&gt;service.name = 'shelfie'&lt;/code&gt;. One note on the counter: it's a monotonic sum, and with the default Rate aggregation my one burst of seed inserts was a barely visible blip. Switching the panel to Increase made it readable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd2w1hmwn8jqo37b7dwj4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd2w1hmwn8jqo37b7dwj4.png" alt="Dashboard with the custom books-created metric and request-count panel" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For alerting I wanted to see an actual delivery, not just a rule turning red. So I wrote a 15-line Python HTTP server that appends every POST body to a file, ran it on port 9999, and created a SigNoz Webhook notification channel pointing at it. One Docker detail: from inside the SigNoz container, &lt;code&gt;localhost&lt;/code&gt; is the container, not my machine. &lt;code&gt;docker inspect&lt;/code&gt; gave me the bridge gateway address (&lt;code&gt;172.19.0.1&lt;/code&gt;), and I tested it with a &lt;code&gt;wget&lt;/code&gt; from inside the container before configuring anything. The probe showed up in my receiver's log, so I knew the path worked.&lt;/p&gt;

&lt;p&gt;Then I made a trace-based alert rule (span count for &lt;code&gt;shelfie&lt;/code&gt; above 0 over a 5-minute rolling window, a threshold chosen to fire immediately under load) and waited. About ten minutes later my receiver logged this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"receiver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"shelfie-webhook"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"firing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"alerts"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"labels"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"alertname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Shelfie traffic alert"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"critical"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"...(current value: 32) crosses the threshold (0)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"related_traces"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:8080/traces-explorer?..."&lt;/span&gt;&lt;span class="p"&gt;}}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the rule showed Firing in the UI:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhv07wmgvjtgmesckkr9s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhv07wmgvjtgmesckkr9s.png" alt="Alert rule in Firing state" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;related_traces&lt;/code&gt; field in the payload is a nice touch. It's a link that opens the traces explorer already filtered to the service and the exact time window that fired the alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I'd tell someone doing this tomorrow
&lt;/h2&gt;

&lt;p&gt;Finish the signup before you debug ingestion. No account means no org, and no org means the collector rejects everything with only a vague log line to show for it.&lt;/p&gt;

&lt;p&gt;The UI is on port 8080. Ignore anything that says 3301.&lt;/p&gt;

&lt;p&gt;Log correlation is effectively opt-in. Auto-instrumentation gives you traces for free, but if all your logs come from an access logger that runs after the span ends, none of them will carry trace IDs. Log from inside the handler and set &lt;code&gt;OTEL_PYTHON_LOG_CORRELATION=true&lt;/code&gt;, then verify by opening a trace's Logs tab. Don't just confirm that logs arrive.&lt;/p&gt;

&lt;p&gt;Probe your alert channel before you create the rule. Testing the webhook path with one &lt;code&gt;wget&lt;/code&gt; from inside the container meant that when the alert didn't arrive instantly, I knew the delay was the evaluation window and not Docker networking.&lt;/p&gt;

&lt;p&gt;Keep the OpenTelemetry Python packages on matching versions. Mixed pins fail at install time in a confusing way.&lt;/p&gt;

&lt;p&gt;All of this ran on one WSL2 machine in an afternoon: six containers, one Flask app, and a webhook receiver that now has a real alert payload sitting in its log file.&lt;/p&gt;

&lt;p&gt;References: &lt;a href="https://signoz.io/docs/" rel="noopener noreferrer"&gt;SigNoz docs&lt;/a&gt;, &lt;a href="https://opentelemetry.io/docs/languages/python/" rel="noopener noreferrer"&gt;OpenTelemetry Python&lt;/a&gt;, &lt;a href="https://signoz.io/docs/userguide/alerts-management/" rel="noopener noreferrer"&gt;SigNoz alerting&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opentelemetry</category>
      <category>observability</category>
      <category>docker</category>
      <category>python</category>
    </item>
  </channel>
</rss>
