Preparing JSONL Training Data for Language Models
Every major fine-tuning provider takes JSONL. They do not all take the same JSONL. Here is what each one expects, and how to check your file before a training run finds out for you.
Why JSONL Became the Universal Training Format
Training data has one job: be readable one example at a time, by a machine, without ceremony. JSONL does exactly that and almost nothing else, which is why OpenAI, Google, Mistral and every open-source trainer worth using converged on it independently. The disagreements are about field names, never about the container.
One Example, One Line
The line is the record. A reader never holds more than one example in memory, a corrupt example damages exactly one line, and there is no closing bracket to forget.
- Streamable: train on a file larger than your RAM
- Appendable: add examples with one write, no rewrite
- Shardable: split on newlines and every piece is valid
- Countable: your dataset size is the line count
It Survives Review
Training data is code someone has to review, and JSONL is the only common format where the diff is legible: fifty new examples are fifty added lines.
- Line-level diffs in code review
- Line numbers in every error message
- Standard text tools work: grep, sort, shuf, split
- Compresses well, so shipping it is cheap
The Chat Messages Shape
If you learn one shape, learn this one. A training example is one JSON object with a messages array, and each message carries a role and a content string. OpenAI uses it, Mistral uses it, Hugging Face TRL uses it, and axolotl's recommended dataset type reads it directly.
What a Training File Looks Like
Two examples, two lines. Nothing wraps them, nothing separates them but a newline.
{"messages": [{"role": "system", "content": "You are a support agent for an invoicing product. Answer in two sentences."}, {"role": "user", "content": "How do I void an invoice?"}, {"role": "assistant", "content": "Open the invoice and choose Void from the actions menu. Voiding keeps the record for your audit trail but removes it from the customer balance."}]}
{"messages": [{"role": "system", "content": "You are a support agent for an invoicing product. Answer in two sentences."}, {"role": "user", "content": "Can I change the currency after sending?"}, {"role": "assistant", "content": "No, the currency is locked once an invoice is sent. Void the original and issue a replacement in the currency you want."}]}What Each Role Means
- system - the standing instruction that frames the task. Optional, but if you will send one at inference time, train with it, and use the same text on every line.
- user - the input. The half of the example the model is given, not the half it learns to produce.
- assistant - the output you want. This is the training target.
- tool - the result handed back after a tool call, used when teaching function calling. Supported by OpenAI, Mistral and most chat templates.
The Assistant Turn Is the Training Target
This is the most useful fact about the format and the one most often skipped. Loss is computed only on the tokens of assistant messages; Mistral states it outright, and every chat-template trainer behaves the same way. System and user turns are context. They condition the model and they cost you tokens, but the model is never scored on reproducing them.
Three consequences follow. An example ending on a user turn teaches nothing. The quality of your assistant text matters far more than the number of examples, because that text is literally what you are copying into the model. And where a per-message weight of 0 or 1 is supported, you can keep earlier assistant turns for context while training only on the final one.
{"messages": [
{"role": "system", "content": "You extract structured fields from shipping notices."},
{"role": "user", "content": "Notice: PO 44812 ships 3 pallets from Memphis on Tuesday."},
{"role": "assistant", "content": "acknowledged", "weight": 0},
{"role": "user", "content": "Now give me JSON."},
{"role": "assistant", "content": "{\"po\": \"44812\", \"pallets\": 3, \"origin\": \"Memphis\", \"ship_day\": \"Tuesday\"}"}
]}Pretty-printed above for readability only. In a real file that whole object must be flattened onto one physical line. A JSON object spread across several lines is not JSONL, and every provider will reject it.
Multi-Turn Examples
A single line can hold an entire conversation, as long as it still ends on an assistant turn. This is how you teach a model to hold context or ask a clarifying question before answering.
{"messages": [{"role": "user", "content": "Book me a room."}, {"role": "assistant", "content": "Happy to help. Which city and which nights?"}, {"role": "user", "content": "Austin, the 14th to the 16th."}, {"role": "assistant", "content": "Two nights in Austin, checking in on the 14th and out on the 16th. Do you have a budget or a neighborhood in mind?"}]}Preference Data for DPO
Supervised fine-tuning teaches a model what a good answer looks like. Preference tuning teaches it which of two plausible answers is better, which is often an easier labeling job: reviewers do not have to write the ideal response, only pick a winner. Direct Preference Optimization and its relatives all consume the same triple.
Prompt, Chosen, Rejected
In the open-source world this shape is settled. TRL reads three columns, prompt, chosen and rejected, in either a plain-string form or a conversational form where each value is itself a messages array. TRL recommends the explicit prompt over folding it into both completions.
{"prompt": [{"role": "user", "content": "Explain a rollback to a non-engineer."}], "chosen": [{"role": "assistant", "content": "A rollback is putting the previous version of the software back in place, the way you would restore a document to yesterday's draft."}], "rejected": [{"role": "assistant", "content": "We revert the deployment artifact to the prior immutable tag and re-point the ingress."}]}Two variants are worth knowing. An unpaired record swaps the pair for a single completion plus a boolean label, which is what you get when reviewers rate answers one at a time. A stepwise record carries a completions array with a matching labels array, for grading reasoning chains step by step.
The Same Idea, a Different Envelope
OpenAI carries identical information under different keys: the prompt sits inside an input object holding the messages array, and the two candidates are preferred_output and non_preferred_output, each an array of assistant messages. As of 2026 the documented constraint is one-turn conversations, with the preferred and non-preferred messages as the last assistant message.
{"input": {"messages": [{"role": "user", "content": "Explain a rollback to a non-engineer."}]}, "preferred_output": [{"role": "assistant", "content": "A rollback is putting the previous version of the software back in place."}], "non_preferred_output": [{"role": "assistant", "content": "We revert the deployment artifact to the prior immutable tag."}]}Keep the pairs honest. A rejected answer that is obviously broken teaches almost nothing. The useful pairs are the ones where both answers are defensible and one is better on the axis you care about: tone, length, format, or hedging.
How the Providers Differ
Every provider below wants JSONL, one example per line. What changes is the envelope and the vocabulary inside it. Keep your data in a neutral internal shape and emit these at the last moment, and switching providers costs an afternoon.
OpenAI
The plain messages shape, uploaded through the Files API and referenced by id when you create the job. Optional per-example fields cover function calling: a tools array describing callable functions, a parallel_tool_calls flag, and tool_calls on assistant messages. The documented minimum is ten examples, and you want considerably more than that.
{"messages": [{"role": "system", "content": "You classify support tickets."}, {"role": "user", "content": "My card was charged twice."}, {"role": "assistant", "content": "billing/duplicate-charge"}]}Google Vertex AI
The odd one out, and the one that breaks naive converters. Vertex mirrors the Gemini request body, not the chat-completions body. There is no messages key: turns live in contents, the assistant role is spelled model, and text nests one level deeper inside a parts array. The system prompt is a separate top-level systemInstruction object rather than a first message, multimodal turns replace text with a fileData object carrying a mime type and URI, and the dataset is read from a Cloud Storage bucket instead of being uploaded through an API.
Note that systemInstruction carries only parts. It takes the same Content object as a turn, but the role field on it is not used - Gemini defines exactly two role values, user and model, and there is no system role. Writing one is a common conversion bug when porting from an OpenAI-shaped file, where the system prompt genuinely is a message with a role.
{"systemInstruction": {"parts": [{"text": "You classify support tickets."}]}, "contents": [{"role": "user", "parts": [{"text": "My card was charged twice."}]}, {"role": "model", "parts": [{"text": "billing/duplicate-charge"}]}]}Mistral
The same messages array, with system, user, assistant and tool as the accepted roles. Mistral's docs are unusually explicit about two things worth repeating to your team: every JSON object must be flattened onto a single line, and loss is computed only on assistant tokens, so a conversation ending on a user turn is processed for nothing. Jobs accept a list of training files each with its own file-level weight, a clean way to blend a large generic set with a small high-quality one.
{"messages": [{"role": "user", "content": "My card was charged twice."}, {"role": "assistant", "content": "billing/duplicate-charge"}]}Hugging Face TRL
The most flexible of the group, because it names formats rather than prescribing one. Conversational language modeling is the familiar messages array; a standard record is a bare text string; prompt-completion splits into prompt and completion; preference adds chosen and rejected. The trainer you pick decides which type you need, and your tokenizer's chat template decides how messages become tokens. Tool-calling data takes an extra tools column of JSON schemas.
{"messages": [{"role": "user", "content": "My card was charged twice."}, {"role": "assistant", "content": "billing/duplicate-charge"}]}
{"prompt": "Ticket: my card was charged twice.\nLabel:", "completion": " billing/duplicate-charge"}Axolotl
Axolotl reads JSONL and picks a parser from the dataset type in your config. The recommended type is chat_template, which reads a messages array and renders it through a Jinja chat template. The older sharegpt type is deprecated; an inherited dataset in that style has a conversations array whose keys are from and value, and migrating is a straight rename of the column and both keys.
# axolotl config
datasets:
- path: ./train.jsonl
type: chat_template
field_messages: messagesField Names Side by Side
| Target | Turns live in | Text lives in | Assistant role | System prompt | Preference keys |
|---|---|---|---|---|---|
| OpenAI | messages | content | assistant | first message | preferred_output, non_preferred_output |
| Google Vertex AI | contents | parts[].text | model | systemInstruction | separate tuning method |
| Mistral | messages | content | assistant | first message | n/a |
| Hugging Face TRL | messages or prompt | content | assistant | first message | chosen, rejected |
| Axolotl | messages | content | assistant | first message | via TRL trainers |
Model names and prices are not part of the format. Which checkpoints are tunable, and what a run costs, change on a schedule nobody controls. Treat both as lookups against the provider's own model list and pricing page on the day you launch the job, not as facts copied out of an article, including this one.
Validate Before You Upload
A malformed file usually fails after the upload, sometimes minutes into a queued job, and the error points at a line number in a file you no longer have open. Validating locally takes about a second and turns that into an assertion while the data is still in front of you.
Do not use openai tools fine_tunes.prepare_data. That helper was removed with the v1 rewrite of the Python library and no longer exists, yet a large share of tutorials still open with it. If a guide tells you to run it, that guide has not been touched in years and its other advice deserves the same suspicion.
A Validator You Can Run Right Now
This catches the failures that actually block a job: a line that is not valid JSON, an empty or missing messages array, a role the API does not accept, a content field that is not a string, and an example that does not end on the assistant turn. It streams, so file size does not matter.
import json
import sys
VALID_ROLES = {"system", "user", "assistant", "tool"}
def validate(path):
errors = []
count = 0
with open(path, encoding="utf-8") as f:
for n, line in enumerate(f, 1):
line = line.strip()
if not line:
errors.append(f"line {n}: blank line")
continue
try:
record = json.loads(line)
except json.JSONDecodeError as e:
errors.append(f"line {n}: not valid JSON ({e.msg} at col {e.colno})")
continue
if not isinstance(record, dict):
errors.append(f"line {n}: top level is a {type(record).__name__}, expected an object")
continue
messages = record.get("messages")
if not isinstance(messages, list) or not messages:
errors.append(f"line {n}: messages must be a non-empty array")
continue
for i, msg in enumerate(messages):
if not isinstance(msg, dict):
errors.append(f"line {n}: message {i} is not an object")
continue
role = msg.get("role")
if role not in VALID_ROLES:
errors.append(f"line {n}: message {i} has role {role!r}")
if not isinstance(msg.get("content"), str):
errors.append(f"line {n}: message {i} content is not a string")
if isinstance(messages[-1], dict) and messages[-1].get("role") != "assistant":
errors.append(f"line {n}: last message is not the assistant turn")
count += 1
return count, errors
examples, problems = validate("train.jsonl")
for problem in problems[:25]:
print(problem)
print(f"{examples} examples, {len(problems)} problems")
sys.exit(1 if problems else 0)No Python handy? Drop the file into the JSONL validator and switch on the OpenAI Format option. It checks the chat shape line by line in your browser, nothing is uploaded anywhere, and each message it reports is explained in the error dictionary.
Uploading Once It Passes
The current flow is two calls: upload the validated file, then create the job against the returned file id. Keep the model identifier in one variable, because that is the line you will be changing every few months.
from openai import OpenAI
client = OpenAI()
training = client.files.create(file=open("train.jsonl", "rb"), purpose="fine-tune")
validation = client.files.create(file=open("valid.jsonl", "rb"), purpose="fine-tune")
job = client.fine_tuning.jobs.create(
training_file=training.id,
validation_file=validation.id,
model=BASE_MODEL, # look this up in the provider's current tunable-model list
)
print(job.id)Mistakes That Cost Real Training Runs
Wrapping the File in a JSON Array
By a wide margin the most common failure, and it comes from one habit: calling json.dump on a list. The result is a valid JSON document and an invalid JSONL file, because line one is a bare bracket.
# wrong - one document, comma separated, wrapped in brackets
json.dump(examples, open("train.jsonl", "w"))
# right - one compact object per line
with open("train.jsonl", "w", encoding="utf-8") as f:
for ex in examples:
f.write(json.dumps(ex, ensure_ascii=False) + "\n")Trailing Commas and Hand Editing
JSON has no trailing commas, and an editor will happily let you leave one behind after deleting a field. The same goes for single quotes, unquoted keys, and Python's None, True and False leaking in from a stringified dict. Serialize with a real JSON library, and re-run the validator after any manual edit.
Inconsistent System Prompts
If half your examples carry a system prompt and half do not, or the wording drifts, you have taught the model that the instruction is noise. Pick one string, put it in a constant, emit it on every line, and send that exact string at inference time. A mismatch between training and serving prompts is a leading cause of a model that looks fine offline and disappoints in production.
Raw Newlines Inside Content
Multi-line content is legal as long as the newline is escaped inside the JSON string. A literal newline inside a quoted value is what splits one example into two broken lines, and it is exactly what naive string building produces. Any real serializer handles this for you, which is the whole argument for using one.
{"messages": [{"role": "user", "content": "Line one.\nLine two."}, {"role": "assistant", "content": "Understood."}]}Duplicate Examples
Duplicates arrive quietly, from re-running a scrape or merging two exports, and they are not neutral: a repeated example is upweighted against everything else, so the model overfits whatever happened to be duplicated. Deduplicate on a hash of the normalized record.
Train and Validation Leakage
Splitting before deduplicating puts the same example on both sides, and validation loss becomes a measurement of memorization. Deduplicate first, then split. If your examples are grouped by customer, document or conversation thread, split on the group rather than the row, or a paraphrase of a training example still lands in validation.
Practical Data Preparation
The order is not arbitrary: deduplicate, then split, then shuffle, then measure. Any other order quietly corrupts your evaluation.
Deduplicate, Split, Shuffle
A holdout of five to ten percent is a reasonable default, with a floor of a few dozen examples so the number means something. Seed the shuffle: an unseeded split is not reproducible, and you will want to rerun it.
import hashlib
import json
import random
def load(path):
with open(path, encoding="utf-8") as f:
return [json.loads(line) for line in f if line.strip()]
def fingerprint(record):
canonical = json.dumps(record, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
records = load("all.jsonl")
seen = set()
unique = []
for record in records:
key = fingerprint(record)
if key not in seen:
seen.add(key)
unique.append(record)
print(f"removed {len(records) - len(unique)} duplicates")
random.Random(20260725).shuffle(unique) # seeded, so the split is reproducible
cut = max(20, len(unique) // 10)
valid, train = unique[:cut], unique[cut:]
def dump(path, rows):
with open(path, "w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
dump("train.jsonl", train)
dump("valid.jsonl", valid)Shuffling matters beyond the split. Data that arrives sorted, all the billing tickets and then all the shipping tickets, presents a curriculum you did not intend.
Budgeting Tokens
Training is billed and bounded by tokens, not by examples or megabytes. Total tokens in the training file, multiplied by the number of epochs, is the quantity that drives both cost and wall-clock time. Multiply that by your provider's current per-token training rate for a figure, and read that rate off their pricing page on the day you run.
Count with the tokenizer belonging to the model you are tuning. Character and word counts are not a substitute, and a tokenizer from another model family can be far off on code, JSON or non-English text.
from transformers import AutoTokenizer
import json
tok = AutoTokenizer.from_pretrained(BASE_CHECKPOINT)
lengths = []
with open("train.jsonl", encoding="utf-8") as f:
for line in f:
messages = json.loads(line)["messages"]
text = tok.apply_chat_template(messages, tokenize=False)
lengths.append(len(tok(text).input_ids))
lengths.sort()
total = sum(lengths)
print(f"examples {len(lengths)}")
print(f"total tokens {total}")
print(f"median example {lengths[len(lengths) // 2]}")
print(f"95th percentile {lengths[int(len(lengths) * 0.95)]}")
print(f"longest example {lengths[-1]}")
print(f"tokens x 3 epochs {total * 3}")Look at the tail, not just the average. Examples longer than the training context window get truncated, and truncation eats the end of the text, which is the assistant turn you wanted to learn. If the ninety-fifth percentile is close to the limit, shorten or drop those examples deliberately.
Quality Over Volume
Because loss lands only on assistant text, a few hundred carefully written responses routinely beat tens of thousands of scraped ones. Read a random sample of your own file before you launch anything. If the answers are inconsistent in tone, length or format, the tuned model will be inconsistent in exactly those ways, and more data does not fix it.
Version the file the way you version code: a dated filename, a note of what changed, and a seed that reproduces the split. When a tuned model regresses, the first question is which data produced it, and JSONL answers it with a line-level diff.
Provider Documentation
Training data formats change without notice, and no summary stays correct forever. Every format on this page was checked against the provider's own documentation on 07-25-2026. Confirm against these before you spend money on a training run.
- OpenAI supervised fine-tuning - the chat
messagesshape and the Files API upload flow. - OpenAI direct preference optimization - the preferred and non-preferred output envelope.
- Google Vertex AI text tuning - the tuning workflow and dataset requirements.
- Gemini generateContent reference - defines the Content object that both
contents[]andsystemInstructionuse, and the two valid role values. - Mistral fine-tuning - roles and the assistant-only loss rule.
- Hugging Face TRL dataset formats - standard versus conversational, and the preference variants.
- Axolotl - chat template configuration, and why the older sharegpt format is deprecated.
Related Resources
JSONL Validator
Check your training file in the browser, with an OpenAI format option
Machine Learning
Embeddings, model outputs, and streaming datasets
Common Mistakes
The formatting errors that break JSONL files
Error Dictionary
Every validator message, explained and fixed
Best Practices
Production-ready patterns for JSONL pipelines
Tools
Command-line and library support for JSONL