[Project] RL on Text to SQL with Qwen 3B

reinforcement-learning
post-training
text-to-sql
environments
Published

September 8, 2026

I have been getting into RL post training recently, and I wanted a small project that would test my understanding of the implementation side rather than the theory: build an environment, write a reward, generate the data, train a model, and read the results. This is that project. I trained a 3B code model with GRPO to write SQL for one real bank database, using execution feedback as the only reward, over two runs and about 3 days. The interesting part turned out to be why the first run stalled, and what I found in the benchmark while working out why.

The task

Text to SQL maps a natural language question about a database to a SQL query that answers it. The following test question from this project illustrates the setting:

How many accounts who choose issuance after transaction are staying in East Bohemia region?

The gold query returns 13:

SELECT COUNT(T2.account_id)
FROM district AS T1
INNER JOIN account AS T2 ON T1.district_id = T2.district_id
WHERE T1.A3 = 'east Bohemia' AND T2.frequency = 'POPLATEK PO OBRATU'

Producing it requires four pieces of knowledge that the question does not contain: that “issuance after transaction” corresponds to the value POPLATEK PO OBRATU in the frequency column, that “region” is the column A3, that the stored value is east Bohemia in lowercase, and that accounts join to districts on district_id. The difficulty of the task is the distance between how a question is phrased and how the data is stored.

That distance is also what makes the task well suited to reinforcement learning with verifiable rewards. The reward is exact and cheap to compute: execute the model’s query and the gold query, and compare the result sets. No judge model or rubric is involved. And because the knowledge being acquired is specific to one schema, a small model trained on that schema has a plausible path to matching a much larger model that has never seen it.

The financial database

The database is financial from the BIRD benchmark, the PKDD 1999 Czech bank dataset. It has eight tables covering clients, accounts, the dispositions that link them, transactions, loans, credit cards, standing orders, and district demographics.

Three properties of this schema distinguish it from the other BIRD databases I considered, and each one is a distinct thing the model has to learn.

The schema does not describe itself. In most databases the column names and values carry meaning a model can read directly: a column called salary holds salaries. Here the demographic columns are named with codes, and the categorical values are abbreviations in Czech. The mapping from an English phrase to a column or a stored value has to be learned from experience with the database or supplied in the prompt; it cannot be inferred from the schema text. This is the knowledge that separates a model that has trained on this database from one that has only read the schema.

The relationships between entities are indirect. Clients and accounts are not linked to each other. Both link to a dispositions table that records each client’s role on each account, so any question that connects a person to their transactions or loans passes through a table the question never mentions. Clients and accounts also each have their own district, and for 494 clients the two differ. The correct join path has to be reasoned out from the structure rather than read off the question, and a model that has not learned the structure tends to invent the direct link it expects to find.

Exact values matter. String comparisons in the database are case sensitive, and questions are written in ordinary English capitalization while values are stored as they were entered. A query that is right in every other respect returns nothing if a region name is capitalized differently. The reward gives no credit for almost right.

Why this database. Before choosing a database, I scanned five BIRD databases with two models: Claude Sonnet 5 as a frontier reference, and the untrained 3B model sampled eight times per question so that I could measure not just accuracy but how many questions had mixed outcomes, the quantity GRPO learns from.

Database Sonnet, hints / none 3B, hints / none 3B mixed groups of 40
financial 78 / 53 15 / 7 17 / 10
card_games 60 / 55 45 / 28 17 / 15
codebase_community 75 / 65 47 / 47 16 / 23
european_football_2 72 / 68 53 / 25 22 / 18
toxicology 60 / 57 38 / 12 18 / 13

financial had the largest gap between the frontier model and the small one, and the largest gap between the hinted and unhinted scores for the frontier model, which is the signature of a database where the missing ingredient is schema knowledge rather than SQL skill. Reading the 3B model’s failures on it confirmed that: wrong join keys, invented columns, the reserved word order left unquoted. Those are learnable errors. It also executes quickly, which matters when the reward loop runs hundreds of thousands of queries.

Setup

Everything in this section is shared by both runs: how a reply is scored, what it is tested on, and how the model is trained. The two runs differ only in the prompt and the training data, which each run describes for itself.

The reward

The model gets the schema and a question and has to return one SQL query in a code block. The verifier runs that query against a read only copy of the database, runs the gold query, and compares the two result sets. The reward is 1 if they match and 0 otherwise. The rules that fill in “match”:

  • Row order is ignored unless the question asks for a ranking, in which case it is compared as a list.
  • Duplicate rows are collapsed. This is how BIRD’s own execution accuracy metric works.
  • The number of columns has to match. Returning the right answer plus an extra column scores 0.
  • Numbers compare numerically, so 1 and 1.0 are equal. Strings compare exactly, case included.
  • An empty result never scores. Neither does a result that is all NULLs, a query that errors, or one that runs past 30 seconds.
  • More than one code block, or none, scores 0. There is no partial credit for anything.

I also added a rule that any result over 1,000 rows scores 0, to keep giant accidental queries from slowing the trainer. That rule was a mistake, and I will come back to it.

The environment is written against Prime Intellect’s verifiers library and trained with their prime-rl trainer, GRPO with vLLM generating rollouts on one GPU and the update on another.

The test set

The test questions come from BIRD, the benchmark most text to SQL work has reported on since 2023. BIRD’s authors took 95 real databases across 37 domains and had annotators write questions about them along with the SQL that answers each one, about 12,000 pairs in all. Two things set it apart from earlier benchmarks. The databases are messy in the way real ones are, with coded columns, inconsistent values, and tables large enough that a query has to be right rather than approximately right. And each question comes with a hint, a sentence of external knowledge the annotator thought a solver would need, such as “A3 contains the data of region” or “when the account type = ‘OWNER’, it’s eligible for loans.” Systems are normally evaluated with the hint supplied.

BIRD’s public dev split has 1,534 questions across 11 databases, and 106 of them are about financial. Those 106 are the test set for everything in this post. They come labeled by difficulty, 62 simple, 37 moderate, and 7 challenging, and I score every checkpoint on them twice, once with the hints and once without. The dropdown has one question from each difficulty, with its hint and gold query, to give a sense of what the annotators’ English looks like.

Simple. List out the no. of districts that have female average salary is more than 6000 but less than 10000?

Hint: A11 refers to average salary; Female mapps to gender = ‘F’

SELECT COUNT(DISTINCT T2.district_id) FROM client AS T1
INNER JOIN district AS T2 ON T1.district_id = T2.district_id
WHERE T1.gender = 'F' AND T2.A11 BETWEEN 6000 AND 10000

Moderate. Among the account opened, how many female customers who were born before 1950 and stayed in Sokolov?

Hint: Customers refer to clients; Female refers to gender = ‘F’; Names of districts appear in column A2

SELECT COUNT(T2.client_id) FROM district AS T1
INNER JOIN client AS T2 ON T1.district_id = T2.district_id
WHERE T2.gender = 'F' AND STRFTIME('%Y', T2.birth_date) < '1950' AND T1.A2 = 'Sokolov'

Challenging. For loans contracts which are still running where client are in debt, list the district of the and the state the percentage unemployment rate increment from year 1995 to 1996.

Hint: Unemployment increment rate in percentage = [(unemployment rate 2016 - unemployment rate 2015) / unemployment rate 2015] * 100; unemployment rate 2015 appears in the A12; unemployment rate 2016 appears in the A13; Loan contracts which are still running where client are in debt can be presented as status = ‘D’

SELECT CAST((T3.A13 - T3.A12) AS REAL) * 100 / T3.A12 FROM loan AS T1
INNER JOIN account AS T2 ON T1.account_id = T2.account_id
INNER JOIN district AS T3 ON T2.district_id = T3.district_id
WHERE T1.status = 'D'

The questions are exactly as the annotators wrote them, typos and all. Two later corrections of these same questions exist, and from run 2 on I score on those as well; the benchmark section near the end explains them. A leak filter removed any training row whose result set or SQL shape matched one of the 106, so nothing in the pool is a paraphrase of a test question.

Model and training loop

I chose Qwen2.5-Coder-3B-Instruct. I wanted a code model rather than a general one because its base accuracy on SQL is higher, and under GRPO that matters directly: the algorithm only learns from questions where some samples in a group succeed and others fail, so a model that already gets some answers right has more to learn from. I used the instruct checkpoint so that it would follow the output format without a supervised stage, and I stayed at 3B because a training pass fits on one GPU and a specialization result at that size is more interesting than at 30B.

I trained with LoRA at rank 32 rather than fine tuning the full model, for memory and speed. RL needs very little capacity, since each episode delivers roughly one bit of information, and Schulman et al. showed in 2025 that LoRA matches full fine tuning for policy gradient methods even at very low rank when applied to every layer.

The training loop is prime-rl, which runs as three concurrent processes: an inference server on one GPU generating replies with the current policy, an orchestrator that draws 48 questions per step, samples 8 replies for each at temperature 1, scores them with the verifier, and packs the batch, and a trainer on the second GPU that updates the adapter and broadcasts the new weights. I used its default GRPO configuration unchanged. The advantage is the reward minus the group mean, groups where every reply scored the same are dropped, and the asynchronous pipeline and that zero variance filtering are the parts of the recipe the ScaleRL paper recommends. AdamW at a learning rate of 2e-5, 250 steps, and an evaluation on the 106 test questions every 25 steps, without hints and again with hints.

Run 1

The first run was deliberately minimal: the instruct model, no supervised stage, and a prompt that withheld everything except the schema, so that anything the model learned about this database could be attributed to execution feedback alone.

Prompt

Each training example is a single prompt and a single reply. The prompt has three parts: a fixed system instruction, the schema as CREATE TABLE statements with three sample rows per table, and the question. The full prompt is about 1,900 tokens; the dropdown shows it for one question, with the schema cut down to one of the eight tables.

You are an expert SQLite analyst for a bank's database. Given the database schema
and a question, write ONE SQLite query that answers the question exactly: the
requested columns and no others, the requested ordering and limit if any. Return
only the query inside a single ```sql fenced block.

Database schema:

CREATE TABLE account
(
    account_id  INTEGER default 0 not null primary key,
    district_id INTEGER default 0 not null,
    frequency   TEXT   not null,
    date        DATE   not null,
    foreign key (district_id) references district (district_id)
);
/* 3 sample rows from account:
account_id | district_id | frequency | date
1 | 18 | POPLATEK MESICNE | 1995-03-24
2 | 1 | POPLATEK MESICNE | 1993-02-26
3 | 5 | POPLATEK MESICNE | 1997-07-07
*/

... seven more tables ...

Question: How many accounts in Beroun were opened after 1996?

Answer with a single SQLite query in a ```sql block.

The model replies with one SQL query in a code block, and the verifier does the rest.

The prompt contains the schema but nothing about what its coded columns or Czech values mean, and no hint. In the standard BIRD setting a system gets all three, and I withheld them on purpose: that knowledge is what distinguishes a model trained on this database from one that has only read its schema, and I wanted to know whether the execution reward alone could teach it. Run 2 revisits this.

Training data

Since the only human written questions about this database are the 106 I use as the test set, the training questions had to be generated, and this is where most of my effort went. In RL with a verifiable reward, the training set effectively is the environment. Whatever shapes and phrasings it contains are what the model can learn, and any incorrect gold it contains gets learned as well. Two decisions set the design, and the rest of this section describes how I carried each one out and checked it. First, the gold SQL would come from templates rather than from a model, so that its correctness is guaranteed by construction. Second, difficulty would be organized as a curriculum over SQL structure, which is the standard axis in text to SQL work and the one I could control directly.

The semantic layer. Because the schema does not describe itself, my first step was to write down what everything means: each coded district column, each Czech value in each categorical column, and a one line description of each table. This legend is the source of truth for every English question the generator produces, and it is what the rewriting model sees when it rephrases a question. In run 1, I deliberately kept it out of the training prompt.

Tiers. Before writing any templates, I defined four tiers according to the SQL structure a question requires. Each template family belongs to exactly one tier.

Tier Structure Example
1 One table: a filter, a count, an aggregate, or a top k Which 5 districts have the highest number of committed crimes in 1996 per 1000 inhabitants?
2 One join: a filter on one table, output or aggregate from the other How many female clients in the region ‘north Bohemia’ were born in the 1950s?
3 Two or three joins with grouping, a HAVING clause, or a ranking What percentage of female clients in the region ‘south Bohemia’ were born before 1950?
4 Subqueries, set operations, date arithmetic, conditional aggregation Which loans in ‘north Moravia’ have a higher monthly payment than the average for loans in their own district?

The tiers form a curriculum in the sense that matters for GRPO. A question only contributes a gradient when the model is sometimes right on it, so the pool needs questions at the edge of what the untrained model can already do, along with harder ones that become learnable later.

Templates. A template is a function that samples real values from the live database, builds a SQL query from those choices, and renders an English question from the same choices, so the query and the question cannot disagree. Because the values come from the database itself, every filter is guaranteed to match something. For example, one tier 3 template picks a region, a transaction category, and a year:

SELECT SUM(T1.amount)
FROM trans AS T1
INNER JOIN account AS T2 ON T1.account_id = T2.account_id
INNER JOIN district AS T3 ON T2.district_id = T3.district_id
WHERE T3.A3 = 'west Bohemia' AND T1.k_symbol = 'DUCHOD' AND STRFTIME('%Y', T1.date) = '1997'

What was the total amount of old-age pension transactions in 1997 for accounts in the region ‘west Bohemia’?

I ended up with 34 families across the four tiers. Each family is a single generator with several variants inside it. They were written in three waves: a base set for each tier, an extension set covering shapes the base set had missed, and a final set added after I compared the pool’s SQL features against the test questions, which I describe at the end of this section.

Counts are rows in the run 1 pool after all filters.

Tier Family Rows What it generates Example question
1 t1_base 149 count with a filter, single column aggregate, top k by a district column, list with a filter Which district has the lowest ratio of urban inhabitants? Give the district name.
1 multi 86 two or three filters on one table List the districts whose ratio of urban inhabitants is below 63 and whose number of entrepreneurs per 1000 inhabitants is above 100.
1 dates 84 date ranges on one table Between the start of month 3 and the end of month 5 of 1997, what was the total loan amount granted?
1 text 41 LIKE patterns on names List the names of districts that start with ‘M’.
1 arith 36 arithmetic between columns, ranked Which 5 districts have the highest number of committed crimes in 1996 per 1000 inhabitants?
1 nulls 15 filters on missing values How many withdrawal transactions in 1996 have no partner bank recorded?
1 distinct 9 distinct counts How many distinct accounts had at least one credit card withdrawal in 1997?
2 t2_extra 369 fifteen one join shapes across loans, orders, cards, clients, and districts How many permanent orders for household payment are on accounts in district id 34?
2 multi_join 80 one join with two or three filters How many female clients in the region ‘north Bohemia’ were born in the 1950s?
2 t2_base 75 one join, filter on one side, count or aggregate on the other How many female clients live in districts whose unemployment rate in 1996 is above 3?
2 text_join 67 LIKE pattern through a join How many loans with status running contract are on accounts in districts whose name starts with ‘R’?
2 date_join 63 date range through a join How many loans were granted between month 2 and month 6 of 1996 on accounts in the region ‘Prague’?
2 exists 58 EXISTS and NOT EXISTS lookups Which districts in the region ‘central Bohemia’ have at least one gold card issued on their accounts?
2 arith_join 48 arithmetic across a join For the district ‘Plzen - mesto’, what is the total loan amount per inhabitant?
2 distinct_join 11 distinct count through a join How many distinct clients are linked to an account with a loan in the region ‘Prague’?
3 trans_agg 125 aggregates over the transaction table by type, category, or period For account id 309, what is the total transaction amount per transaction type?
3 chain_lookup 119 three or four joins ending in a single fact What is the birth date of the owner of the account that permanent order id 34511 belongs to?
3 percentage 106 “what percentage of” with conditional aggregation What percentage of female clients in the region ‘south Bohemia’ were born before 1950?
3 t3_extra 88 fifteen grouped and ranked shapes across regions and districts Among account owners living in ‘south Moravia’, how many are male and how many female?
3 t3_base 67 group by with top k, HAVING, or per group aggregates For accounts held in the district ‘Trebic’, how many cards of each type were issued?
3 chain_count 65 a count at the end of a chain, often with an nth highest district condition How many male clients live in the district with the second lowest number of crimes in 1995?
3 multi_group 37 grouping with several filters Among clients in ‘east Bohemia’ born in the 1970s, how many are there of each gender?
3 ratios 23 ratios between counts with a minimum group size Among districts with at least 40 accounts, which 3 have the most cards per 100 accounts?
3 date_groups 23 grouping by quarter or year How many accounts were opened in each quarter of 1993?
3 distinct_groups 8 distinct counts per group For each region, how many distinct permanent order purposes appear?
4 trans_hard 88 first or largest transaction per account, transaction conditions on accounts How many accounts with monthly statement issuance had their first transaction in 1997?
4 t4_base 77 above average comparisons, both years, EXCEPT pairs, shares, age at loan Which districts have an average salary below the average across all districts?
4 correlated 73 correlated subqueries against a group’s own average In ‘north Moravia’, which loans have a higher monthly payment than the average in their own district?
4 antijoin 52 accounts or clients with no matching row How many accounts in ‘Prague’ have never had a transaction for household payment?
4 setops 40 INTERSECT and EXCEPT In ‘east Bohemia’, which district ids have loans but none with the client in debt?
4 nth 25 second or third highest with a follow up fact Who owns the account with the second largest loan? Give the client id.
4 date_arith 23 comparisons between two dates How many loans were granted in the same calendar year the account was opened?
4 nested_agg 12 an aggregate of an aggregate Within the region ‘Prague’, which district has the most loans?
4 cond_having 10 HAVING on a conditional aggregate Which regions have more than 5 percent of their loans running with the client in debt?

Every generated query is executed before it can enter the pool, and it is kept only if it passes a fixed set of filters.

  • The query executes without error and returns at least one row.
  • The result is not entirely NULL.
  • The result has at most a few hundred rows. Larger results are slow to compare and are almost always a template that forgot a filter.
  • Ranking templates check that the cutoff does not fall on a tie before emitting a question, so that the top k is unambiguous.
  • Two rows with the same SQL skeleton, the query with its literals replaced by placeholders, and the same result set are duplicates, and only the first is kept.
  • Each skeleton is capped at a fixed number of rows per tier so that no single shape dominates the pool.
  • The mechanical checker described under audits runs on every row and rejects the known bug classes.

Pilot. Before scaling up, I generated 200 rows, 50 per tier, and read 80 of them myself. That alone turned up three generator bugs. The legend had no table descriptions, so questions about permanent orders came out as questions about orders. One tier 4 template could return a NULL row, and described a decrease as an increase. One tier 2 template about average salary was ambiguous between the per loan and per district readings. I fixed all three before running the full generation.

Model written SQL. Templates guarantee correctness but offer limited variety, so I also tried having Claude Haiku write SQL directly from questions for the harder tiers, hoping to cover shapes the templates did not. Between half and ninety percent of what it wrote executed, which looked promising at first. But when I audited a sample, executing each query and checking the result against the question, only 72 percent was actually correct. A plausible looking gold query that is wrong is worse than no gold at all, because the policy learns the mistake and the reward confirms it. I dropped all 804 rows and kept only the templates.

Rewriting the English. Template questions are stilted and all sound alike, and that uniformity is its own kind of leakage, since a model could learn the template grammar instead of the task. To break it, I had Haiku rewrite each canonical question in one of six registers: formal, concise, interrogative, imperative, colloquial, or descriptive. The rewriting uses the question synthesis prompt published with OmniSQL, with my legend supplied as the column documentation. In the imperative register, the tier 3 example above became:

Could you please calculate the total amount of all old-age pension transactions that took place in 1997 for accounts located in the west Bohemia region?

My first design filtered the rewrites by whether Haiku could solve them from the rewritten question alone. I noticed only after building it that this would cap the training set at Haiku’s own ability. I replaced it with a reading check, in which a separate pass scores each rewrite on six criteria using a four level scale, and a row is kept only if the four gating criteria are all rated Good or Excellent. I kept the solvability signal as a difficulty tag and never used it as a filter.

  1. Result alignment. Does the query return exactly the columns the question asks for, with no extra columns, and without the question asking for rounding or formatting the query does not produce?
  2. Structural alignment. Does the structure of the query, its predicates, grouping, ordering, limit, and set operations, reflect the logic of the question?
  3. Answer adherence. Is the query a correct and complete solution to the question as posed?
  4. Unambiguous phrasing. Can the question be read only one way?
  5. Real world relevance. Is it a question someone would plausibly ask of this database?
  6. Proper grammar.

The first four gate a row. The last two are logged and used to spot check the rewriting prompt.

Audits. Correct by construction turned out to be a weaker guarantee than it sounds. I ran three rounds of audits in which eight agents each executed 25 generated rows against the database and independently compared the results to the English, and I merged their verdicts so that only findings agreed on by two or more agents counted. The rounds found 18, then 8, then 7 template bugs. I turned every bug class into a mechanical check that now runs on every generated row.

Class What went wrong Check
Text affinity Four district columns hold numbers but are stored as text, so sorting or comparing them without a cast is alphabetical Any comparison or ORDER BY on those columns must be wrapped in a CAST
Ties at LIMIT A top 3 question where the third and fourth rows tie has two correct answers The kth and k+1th ordering keys must differ
NULL in result An aggregate over an empty group returned a NULL row that the question did not describe Any NULL in the result rejects the row
Grain The question said the client’s district while the SQL joined through the account’s district Templates are tagged with the entity whose district they use and the English is rendered from the tag
Withdrawal codes The code for a cash withdrawal appears in two columns with different meanings Templates choose the column explicitly and the English names it
Month ends Date ranges built as the 30th of a 31 day month Ranges are built as half open intervals on the first of the next month
No op filters A filter that every row satisfies, so the question implied a restriction that did not exist A filter must exclude at least one row
Unordered LIMIT A LIMIT with no ORDER BY, whose result depends on storage order Rejected
Reserved word The order table used unquoted Always quoted

Matching the test set. Finally, I compared the SQL features of the pool against the 106 test questions. Three kinds of question that BIRD asks often were missing from the templates: lookups that chain three or four joins to reach a single fact, “what percentage of” questions, and second or third highest rankings. I added families for each, chain_lookup, chain_count, percentage, and nth in the inventory above, built from the database’s own entities and phrasings rather than from any test question. A leak filter then removed any row whose result set or SQL skeleton matched one of the 106.

The first run trained on 2,252 rows: 420 in tier 1, 771 in tier 2, 661 in tier 3, and 400 in tier 4.

Result

The chart below is the test accuracy every 25 steps: the fraction of the 106 human questions whose query returned the gold result, one sampled reply per question, without hints and with them. The dashed lines are Claude Sonnet 5 with no training, scored the same two ways.

Without hints the model went from 9 percent to 34 percent. With hints, from 28 to 46. Sonnet scores 53 and 78.

The endpoints are less interesting than the shape. Everything the no hint score gained, it gained by step 50, and it then sat between 31 and 36 for two hundred more steps. Nine evaluations in a row inside a five point band is not noise. My first assumption was that the model was still improving on its training data and the test set had simply stopped rewarding that, so I plotted the two together.

The mustard line is the reward on the training pool at every step, the fraction of the 384 sampled replies that matched gold, with the raw values faint behind a ten step average. It tracks the test curve almost exactly: a climb to about 35 percent by step 50, then a slow drift to 38 by the end. The model was not mastering its own training data either. Sixty percent of the pool, questions whose gold query I had generated and verified, was still being answered wrong at the end of training. Whatever was blocking the test questions was blocking the training questions too, and the second run’s version of this chart, later in the post, is the clearest single picture of what changed.

BIRD labels each of its questions simple, moderate, or challenging. Splitting the test accuracy by that label, at every evaluation, with and without hints, shows where the flat line comes from.

The seven challenging questions were never solved without hints. The 37 moderate ones crawled into the twenties by step 50 and wandered there. The plateau is the 62 simple questions: a jump to 48 percent by step 50, then nothing. For the second half of training the model was failing more than half of the questions BIRD considers easy.

Comparing the two conditions separates two kinds of failure. At the final step, hints lift the moderate questions from 24 to 46 percent, so those failures are largely vocabulary, the model not knowing which column or value a phrase refers to. The simple questions move only from 44 to 52. Whatever is wrong with the thirty or so simple questions the model cannot solve, telling it which column to use does not fix it.

A model that has run out of capacity does not stall on sixty percent of its own training pool, so something more specific was going on. I went through the failures one at a time before deciding what to change, and that is where run 2 starts.

Run 2

Post mortem

Two things run 1 clearly did learn set the baseline for reading its failures. Execution errors, replies that were not even valid queries against this schema, fell from 52 percent of answers to 17 percent, and the Czech values were learned from the sample rows alone: at step 0 the model wrote frequency = 'POPLATEK TYDENNE', a guess at the spelling, and by the end it wrote POPLATEK TYDNE. So the failures that remained were not about syntax, and not about values that appear in the sample rows.

I read all 70 answers the model got wrong at step 250 and tracked every one of the 106 questions across the nine evaluations from step 50 onward. 47 questions were never solved in any of them. My first guess was that the model could not infer the coded columns, so I checked whether those 47 were solved when the hint was present. 38 of them were not. Whatever was blocking them, it was not the legend.

Read by hand, the 47 sort into a small number of causes.

Cause Questions Example
A join path the training pool never contained ~15 trans.client_id and disp.district_id, columns the model invented because no training question joined clients to transactions
BIRD’s phrasings, absent from the pool ~12 “eligible for loans” means disposition type OWNER; “high level card” means gold
Capitalization 3 the question says North Bohemia, the database stores north Bohemia
Output column conventions ~8 “which district” expects the name, the model returned the id
Coded column confusion ~6 A10 for salary instead of A11
The verifier’s row cap 5 the gold result has more than 1,000 rows, so no answer could score

The pool had 2,252 questions and not one of them joined the transaction table through a client. Every question that needed the path from a person to their transactions had to go client, then disp, then account, then trans, and the model had never once been rewarded for producing it. Instead it invented one, a client_id on the transaction table or a district_id on the disposition table, and the query failed to execute. That was the block on the simple questions. They were simple in SQL terms and impossible in terms of what the pool taught.

The row cap was my mistake. I had capped results at 1,000 rows to keep runaway queries from slowing the trainer, without checking whether any gold query exceeded it. Five did. One of them had the model’s query identical to the gold in every single evaluation, scoring zero each time.

Two other checks shaped what I did next. I had assumed the unsolved questions were the ones with more joins, and they were not: the never solved questions averaged 2.55 tables and the always solved ones 2.41. And of the roughly 100 wrong answers that did execute, 71 returned a result with nothing in common with the gold. There was nothing for a partial credit reward to work with, which settled a question I had been considering, and it pointed at the same thing as the missing join path: when the model takes the wrong path through the schema, the whole answer is wrong.

This also explained why more steps could never have helped. GRPO learns from the difference between replies in a group, so a question the model gets wrong all eight times contributes nothing, and prime-rl drops those groups before they reach the trainer. Every question needing the client to transaction path was in that state for the entire run. It was never going to see a gradient for it. The fixes had to change what the model could sometimes get right, which means the prompt and the pool.

Prompt and data

The training recipe itself, the model, the adapter rank, the batch, the learning rate, the number of steps, stayed identical to run 1. I wanted the second run to be about the environment, so that whatever changed could be attributed to the prompt, the data, and the reward.

Prompt. The legend went into the prompt: what each coded district column means and what every Czech value stands for. Withholding it in run 1 had been a deliberate bet, and the failure read settled it. The knowledge that makes a specialized model valuable is the schema structure and the phrasings, which no prompt supplies, rather than sixteen column codes that can simply be provided. So the legend became part of the input, as it is for every BIRD system. With it went the join structure written out in plain sentences, one per foreign key and one saying that clients reach accounts only through disp, since the CREATE TABLE statements had that information and a 3B model plainly could not use it in that form. The exact stored values of every categorical column went in too, so that capitalization is given rather than guessed, along with a line on how years are compared and an instruction borrowed from the small model text to SQL papers to only output the information that is asked.

The prompt is about 2,800 tokens, up from 1,900. The dropdown shows the parts that are new, with the schema itself omitted since it is unchanged from run 1.

You are an expert SQLite analyst for a Czech bank's database. Given the database
schema, a legend of its coded columns and values, the join graph, and a question,
write ONE SQLite query that answers the question. Only output the information that
is asked: the requested columns and no others, the requested ordering and limit if
any. String comparisons are case-sensitive: use the stored values exactly as listed.
Return only the query inside a single ```sql fenced block.

Database schema:

... the eight CREATE TABLE statements with three sample rows each, as in run 1 ...

Tables:
  account: a bank account (one row per account); frequency = how often statements are issued
  client: a person; district_id = where the client lives
  disp: disposition: links a client to an account with a role (OWNER or DISPONENT = authorized user)
  card: a credit card issued to a disposition (i.e. to a client on an account)
  loan: a loan granted on an account; status codes below
  order: a PERMANENT ORDER (standing order) set up on an account: recurring payments; k_symbol = purpose
  trans: a transaction on an account (about 1 million rows); type/operation/k_symbol coded below
  district: a district (region-level demographics); the A-columns are coded, see below
District columns (coded):
  A2 = district name
  A3 = region
  A4 = number of inhabitants
  ...
  A11 = average salary
  A12 = unemployment rate in 1995
  A13 = unemployment rate in 1996
  A14 = number of entrepreneurs per 1000 inhabitants
  A15 = number of committed crimes in 1995
  A16 = number of committed crimes in 1996
  A4–A7 are stored as TEXT; CAST them to INTEGER before comparing or sorting.
Coded values:
  account.frequency: 'POPLATEK MESICNE' = monthly statement issuance; 'POPLATEK TYDNE' = weekly
    statement issuance; 'POPLATEK PO OBRATU' = statement issuance after each transaction
  disp.type: 'OWNER' = account owner; 'DISPONENT' = authorized user (disponent)
  card.type: 'gold' = gold card; 'classic' = classic card; 'junior' = junior card
  loan.status: 'A' = contract finished, no problems; 'B' = contract finished, loan not paid;
    'C' = running contract, OK so far; 'D' = running contract, client in debt
  trans.type: 'PRIJEM' = credit (money in); 'VYDAJ' = withdrawal (money out); 'VYBER' = withdrawal in cash
  trans.operation: 'VKLAD' = credit in cash; 'PREVOD Z UCTU' = collection from another bank;
    'PREVOD NA UCET' = remittance to another bank; 'VYBER' = withdrawal in cash; 'VYBER KARTOU' = credit card withdrawal
  trans.k_symbol: 'POJISTNE' = insurance payment; 'SLUZBY' = payment for statement; 'UROK' = interest credited;
    'SANKC. UROK' = sanction interest for negative balance; 'SIPO' = household payment; 'DUCHOD' = old-age pension; 'UVER' = loan payment
  order.k_symbol: 'POJISTNE' = insurance payment; 'SIPO' = household payment; 'LEASING' = leasing payment; 'UVER' = loan payment

Join graph (how the tables connect; there are no other links):
  account.district_id -> district.district_id   (the district where the ACCOUNT is held)
  client.district_id  -> district.district_id   (the district where the CLIENT lives; can differ from the account's)
  disp.client_id -> client.client_id  and  disp.account_id -> account.account_id
      (disp links clients to accounts; client and account are connected ONLY through disp)
  card.disp_id -> disp.disp_id   (a card belongs to a disposition, so to one client on one account)
  loan.account_id  -> account.account_id
  trans.account_id -> account.account_id   (transactions belong to accounts, not to clients; balance lives in trans)
  order.account_id -> account.account_id   (table name must be quoted: "order")
  Typical paths: client -> disp -> account -> {loan | trans | order | district};  card -> disp -> {client | account}.
Dates are TEXT in 'YYYY-MM-DD' form. Compare years with STRFTIME('%Y', col): "in 1996" = STRFTIME('%Y', col) = '1996',
"after 1996" = STRFTIME('%Y', col) > '1996', "before 1996" = < '1996', "between 1995 and 1997" = BETWEEN '1995' AND '1997'.

Distinct stored values of the categorical text columns (exact spelling and case):
  district.A3: 'Prague', 'central Bohemia', 'east Bohemia', 'north Bohemia', 'north Moravia', 'south Bohemia', 'south Moravia', 'west Bohemia'
  account.frequency: 'POPLATEK MESICNE', 'POPLATEK PO OBRATU', 'POPLATEK TYDNE'
  disp.type: 'DISPONENT', 'OWNER'
  card.type: 'classic', 'gold', 'junior'
  loan.status: 'A', 'B', 'C', 'D'
  trans.type: 'PRIJEM', 'VYBER', 'VYDAJ'
  trans.operation: 'PREVOD NA UCET', 'PREVOD Z UCTU', 'VKLAD', 'VYBER', 'VYBER KARTOU'
  trans.k_symbol: 'DUCHOD', 'POJISTNE', 'SANKC. UROK', 'SIPO', 'SLUZBY', 'UROK', 'UVER'
  order.k_symbol: 'LEASING', 'POJISTNE', 'SIPO', 'UVER'
  trans.bank: 'AB', 'CD', 'EF', 'GH', 'IJ', 'KL', 'MN', 'OP', 'QR', 'ST', 'UV', 'WX', 'YZ'
  order.bank_to: 'AB', 'CD', 'EF', 'GH', 'IJ', 'KL', 'MN', 'OP', 'QR', 'ST', 'UV', 'WX', 'YZ'

Question: How many accounts in Beroun were opened after 1996?

Answer with a single SQLite query in a ```sql block.

Data. New template families, derived from the schema graph rather than from the failures, so that the fix is the database’s structure and not the test set. For every foreign key path at one, two, and three hops there is a family of plain lookups with a single filter and no aggregate: the accounts a client owns, the transactions on those accounts, the district of the account a card was issued on. The idea is that the model has to produce the disp path in its easiest form before it can learn the versions with aggregates and conditions stacked on top. Alongside those, a family for date phrasings with BIRD’s year semantics, one for output conventions, and later one for rankings through a join, which the pool had at half BIRD’s rate. The pool grew to 3,246 rows, through the same English rewrite and reading check as before, and three more audit rounds found a single bug, a code that means two different things in two columns.

Counts are rows in the run 2 pool after all filters. The 34 families from run 1 are unchanged.

Family Rows What it generates Example question
hop1 198 one hop: a client’s accounts, an account’s owner or district, a card’s holder, a loan’s account Who is the owner of account 1374? Give the client id.
hop2 246 two hops through disp: a client’s transactions, loans, or orders; the owner of the account behind a transaction Which transactions on the accounts of client 3678 have an amount above 5000? Give the transaction id and the amount.
hop3 121 three hops with one filter: transactions on accounts owned by clients in a region, owners of accounts in a district with a loan of a given status Which clients own an account held in the district ‘Kromeriz’ that has a loan with status contract finished, no problems? Give the client ids.
date_phrase 99 after, before, in, since, and between, with BIRD’s year semantics, on four date columns How many accounts opened in 1995 or later in the region ‘east Bohemia’?
outconv 42 questions whose expected output follows BIRD’s conventions: which district returns the name, who returns the client id List the accounts in Jesenik with weekly statement issuance.
rank 211 highest, lowest, most, and fewest through a join, tie checked Which district in the region ‘west Bohemia’ has the lowest number of committed crimes in 1996?

Verifier. I removed the rule that scored any result over 1,000 rows as zero. It had been there to keep runaway queries from slowing the trainer, but five of the test questions have gold results larger than that, so the rule made them impossible rather than protecting anything. BIRD’s own execution accuracy metric compares full result sets with no cap, and the verifier now does the same.

Evaluation. Four sampled replies per question instead of one, since a single reply on 106 questions moves by six points from noise and the run 1 curve had been hard to read. And a third version of the test set. In 2024, Wretblad et al. went through these exact 106 questions by hand and found problems in 52 of them: 23 with spelling or grammar errors, 17 that were ambiguous, 22 whose gold SQL was wrong, 7 where the question’s capitalization did not match the stored values, and a few others. They published a corrected version with the questions and the gold queries fixed. I had picked this database before finding that paper, so from run 2 on I evaluate on their corrected 106 as well as the original, and treat the corrected number as the one that measures the model rather than the annotators.

Pilot tests

Two questions I did not want to settle by intuition were whether to add a supervised warm up before RL, and whether to let the model reason before answering. Both are standard. The two small model papers closest to this project, SLM-SQL and FINER-SQL, do both, and their models reach the high sixties on full BIRD. But both cost something. A supervised stage narrows the distribution that RL then explores from, and a thinking format makes every reply several times longer. So before renting the training pod I ran a gate: the untrained model, eight replies per question, under each candidate configuration, scored with the training verifier so the numbers would mean what the run’s numbers mean. It took half an hour on one consumer GPU.

Condition Mean accuracy Solved at least once in 8 Mixed groups
Run 1 prompt, 106 questions 6.7 25 25
Run 2 prompt, 106 questions 20.3 42 40
Run 2 prompt with hints 21.9 51 51
Run 2 prompt, think then answer 15.4 52 51
New hop families, run 2 prompt 42.1 85 83

Three results. The prompt alone tripled the untrained model, from 6.7 to 20.3, and it absorbed nearly the whole hint gap: hints had been worth 19 points on top of the run 1 prompt and were worth under 2 on top of the new one. The hop families had mixed outcomes on 83 percent of their questions, which is the regime GRPO learns from, so the model could climb them without a supervised stage and I skipped it. And the thinking format lost. It started five points lower per reply, and 38 percent of its replies never closed the thinking tag. It did solve more questions at least once in eight, which is a real point in its favor, but I had committed in advance to a rule of at least as good per reply, and it was not.

Result

The chart below is every saved checkpoint scored after the run on the original 106 with and without hints and on the corrected 106, eight replies per question, with run 1 shown faintly for comparison.

The final checkpoint scored 50 without hints, 52 with, and 58 on the corrected set. The best checkpoints were 175 and 225 rather than 250, at 51 without hints, 55 with, and 59 on the corrected set, all within noise of each other. Sonnet 5 zero shot, with the run 1 prompt, is 53 and 78.

Almost all of the gain came in the first fifty steps again, and this time the training pool shows why.

In run 1 the pool and the test set plateaued together, at roughly the same value. Here they separate. Reward on the pool kept climbing until about step 150 and saturated at 93 percent, which is close to the fraction of the pool that is solvable at all, while the test set stopped at step 50. Everything the pool could teach about this schema had transferred by then. The remaining hundred steps taught the model more of the pool, and none of it moved the test number. In run 1 the model could not learn its pool; in run 2 it learned the pool completely, and the test set stopped anyway.

The sweep also showed what training past that point does to the model. The fraction of test questions solved at least once in eight replies peaked at step 25 and drifted down while the mean rose. The policy was getting more consistent and slightly less diverse, which is what RL at low entropy does.

Analysis

Did it learn the database

How do we know the model learned the database rather than the test? The training questions are template generated and the test questions are human written, no test question or a paraphrase of one is in the pool, and the reward never touches a test question. So a gain on the 106 is a gain on questions of a kind the model was never trained on. The sweep shows what kind of gain it was, comparing the untrained model to the step 225 checkpoint with eight replies per question.

The untrained model solved one question reliably; step 225 solves 42 reliably. Twenty one questions moved out of never solved, and of the questions the untrained model solved at least sometimes, two regressed. Replies that failed to execute at all, invented columns and bad joins, went from nearly half to under a tenth.

The fourteen questions that went from never solved to reliably solved show what was learned. Two of them, with the untrained model’s answer and the step 225 answer.

Who placed the order with the id 32423? The untrained model wrote a four table join through trans and card and referenced card.account_id, a column that does not exist. Step 225:

SELECT client.client_id
FROM client
JOIN disp ON client.client_id = disp.client_id
JOIN `order` ON disp.account_id = `order`.account_id
WHERE `order`.order_id = 32423

How much, in total, did client number 617 pay for all of the transactions in 1998? The untrained model wrote WHERE account_id = 617, treating a client id as an account id. Step 225 goes through disp to find the client’s accounts and sums the transactions on those.

Both are the client to disp to account path, the one no training question in run 1 contained. What the model learned is the structure of the database, which tables connect to which and through what, and that is exactly what the hop families were built to teach.

Analyzing the errors

At step 100, 45 of the 106 questions were still wrong in all four sampled replies, and 33 of those had been wrong in every evaluation since step 25. I read them and sorted them by what would have to change for the model to get them right.

What would fix it Questions Example
A definition my legend does not contain 14 “eligible for loans” means disposition type OWNER in BIRD’s convention; the model reads it as having a loan and joins the loan table. “Running contract” means status C or D; the model uses only C.
A correct gold query ~10 the question says 1998 and the gold query says 1997; a question about 2021 on a database that ends in 1998
The right output columns 6 the model returns a client’s gender when the question asks who, or every column when it asks for the transaction id
A join the model still invents 6 account.client_id, district.A11 without joining district; down from 18 such errors in run 1
Aggregate logic the model cannot yet do 4 growth rates between two years, conditional sums inside a percentage
Nothing available 5 questions with more than one reasonable reading, where two published corrections give different answers

The largest group is the fourteen phrasings. They are all ordinary English for concepts the database has a code for, and the templates never used them, so the model never learned that “eligible for loans” and “owner” mean the same thing here. The pool phrases each concept exactly one way. A wider spread of phrasings for the same concepts would close most of those fourteen.

One result I found strange was that the hints did not help. In run 1 they were worth up to 19 points; in run 2 the with hints score sits a few points above no hints and at some checkpoints below it, even though a hint literally names the column or value the question needs. I think I know why. The model was never trained with hints. Not one prompt in the pool contains a hint line, so during training it never once saw a sentence like “A3 contains the data of region” and never had a reason to read one. At evaluation a hint is unfamiliar text tacked onto the question, and a policy sharpened on hint free prompts does not know to lean on it. Hints helped in run 1 because they filled a vocabulary gap the model had no other way to fill. In run 2 the legend fills that gap, and what is left in the table above is mostly not the kind of failure a hint fixes. Sonnet, which reads instructions natively, gains 25 points from the same hints. The fix is to put hints in some of the training prompts so the model learns what they are for.

The benchmark

About a third of the questions the model still gets wrong are questions no model could get right, because the published answer is wrong or because the question has more than one reasonable reading. This section is the evidence.

Two corrections. After run 1 it was clear that many of the published gold queries were wrong, so I looked for corrected versions and found two, built in different ways.

Wretblad et al., 2024 Arcwise and UIUC, 2026
What they went through the 106 financial questions BIRD’s 500 question mini dev set, 30 of them on financial
How by hand a database probing agent, then expert review
What they found problems in 52 of 106: 23 spelling or grammar, 17 ambiguous, 22 wrong gold SQL, 7 capitalization errors in 52.8 percent
What they published corrected questions and gold for all 106 corrected gold for the 30

Three answer keys. The 30 questions Arcwise corrected are among the harder ones, 20 moderate and all 7 challenging, so scores on them run well below the full set whichever gold is used. The clean comparison is the step 225 checkpoint on exactly those 30 questions, scored against each of the three sets of gold queries.

Gold queries from Mean accuracy on the 30 Solved at least once in 8
the original BIRD annotators 36 13
Wretblad’s correction 43 17
Arcwise’s correction 44 18

Same model, same questions, and the corrected keys score it 6 to 8 points higher. That difference is the size of the annotation problem on these questions. For comparison, the other 76 questions score 56.

Where they disagree. Since both groups had corrected the same 30 questions, I ran both groups’ gold queries against the database and compared the results. On 17 of the 30 they return the same answer. On 13 they do not. Two teams of experts, each trying to write the right query for the same English sentence, wrote queries that return different results.

Question Wretblad’s query returns Arcwise’s query returns
Which are the top ten withdrawals (non-credit card) by district names for the month of January 1996? district ids district names
List out the account numbers of clients who are youngest and have highest average salary a client id, joining through the client’s district an account id, joining through disp

Those questions do not have a single right answer. A model scored against either key is marked wrong for reading the sentence the other way, and no correction to the key can change that, because the problem is the sentence. Part of this benchmark therefore measures whether the model reads a question the way one particular annotator did.

At frontier scale. A week before this project started, Zhu, Jin, Choi, and Kang, the UIUC group behind the second correction, published with Thinking Machines a paper on RL for text to SQL. They fine tuned Kimi K2.6 with LoRA and reached 91 percent on the corrected mini dev set, above every frontier model and level with a human proxy. They checked 2,500 of BIRD’s training examples, found that 52 percent had wrong gold SQL, rebuilt the training set by hand, and that alone was their largest gain. Two other findings match this project. A third of their execution match rewards went to queries that were not equivalent to the gold, which I saw on the first day when a query that skipped a join matched anyway. And a quarter of their failures came from the model ignoring the supplied hint, which is the with hints result in run 2. The same three problems, at 3B and at a trillion parameters.

What I would change

Four things I would do differently.

More phrasings. Fourteen of the 45 unsolved questions come down to ordinary English the templates never used for concepts the database has a code for: “eligible for loans,” “running contract,” “high level card.” Each template phrases its concept one way, and the rewriting pass changes the register of a sentence but not the words for the concept inside it. Generating several phrasings per concept, the way a person might actually ask, is a change to the generator and to the legend it reads from, and it costs nothing to train.

Hints in training. The model never saw a hint during training, so it never learned to use one, and the with hints score measures almost nothing as a result. Zhu et al. go further and reward the model for honoring each hint as a constraint on the query, which is the better design because it cannot be satisfied by guessing.

Check the rewards. A third of Zhu et al.’s execution match rewards went to queries that were not equivalent to the gold, and I saw the same thing on day one, a query that skipped a join and matched anyway because every card in the database happens to be on an owner’s account. The cheap version of their check is to re-execute every rewarded query on a perturbed copy of the database and count how many stop matching. I have not run it, and until I do the 93 percent training pool number carries an asterisk.

No caps. The row cap made five questions unwinnable for a whole run. BIRD’s metric has no cap, and now neither does mine.

Beyond those, the frontier comparison should be rerun with the run 2 prompt, since the Sonnet numbers above were measured with the run 1 prompt and are a floor on what it would score. A third run would take roughly half the GPU hours of the second, and the first two items above are where I would spend it.

Code and weights

The trained adapters, one from every 25 steps of run 2, and the evaluation traces are on Hugging Face at Induction/qwen2.5-coder-3b-t2s-v2-lora. The environment, the data pipeline, the measurement scripts, the configs, and the per step results are on GitHub at Induction1/rl/text2sql.