The Problem With Parsing Free Text
Why This Problem Exists At All
Every response you've generated so far in this course has come back as response.output_text — a plain string. That's fine when a human is going to read the result. It becomes a real engineering problem the moment your application needs to do something programmatic with the model's answer: store a specific field in a database, populate a form, trigger a different code path depending on a category the model assigned, or pass a number into a calculation. At that point, a string of natural language is not actually the data your code needs — it's a document your code has to first turn into data, and that extra step is where a surprising amount of fragility creeps into LLM-backed applications.
response = client.responses.create(
model="gpt-5.6-luna",
input="Extract the name, age, and city from: 'Maria Gomez, 34, lives in Austin.'",
)
print(response.output_text)
# Something like: "Name: Maria Gomez\nAge: 34\nCity: Austin"
This output is perfectly readable to a person. To a program, it's an unstructured blob that has to be picked apart with string operations before anything useful can be done with it — and that picking-apart step is exactly what this lesson is about, and exactly what the rest of this unit shows you how to avoid needing in the first place.
The Naive Approach: Regular Expressions and String Splitting
The most direct way to turn the text above into usable data is to write parsing code against whatever format the model happened to produce.
import re
def parse_naive(text: str) -> dict:
name_match = re.search(r"Name:\s*(.+)", text)
age_match = re.search(r"Age:\s*(\d+)", text)
city_match = re.search(r"City:\s*(.+)", text)
return {
"name": name_match.group(1).strip() if name_match else None,
"age": int(age_match.group(1)) if age_match else None,
"city": city_match.group(1).strip() if city_match else None,
}
result = parse_naive(response.output_text)
print(result) # {'name': 'Maria Gomez', 'age': 34, 'city': 'Austin'}
This works, for this exact output, on this exact day, with this exact model and prompt. That last clause is the entire problem with this approach: it works only under the assumption that the model will always format its answer exactly the same way, every single time, forever. That assumption does not hold, for reasons that are worth understanding precisely rather than taking on faith.
Why the Model's Formatting Is Not Reliable Enough to Parse
A language model generates text one token at a time, and even with an identical prompt, its output is not guaranteed to be byte-for-byte identical across calls (Unit 3 touched on this when discussing example-driven prompting to steer output shape). A model might, on one call, answer with "Name: Maria Gomez", and on another, equally reasonable-seeming call, answer with "The name is Maria Gomez", or "**Name**: Maria Gomez", or format age as "34 years old" instead of a bare number, or omit a field the parsing code assumes will always be present, or reorder the fields, or add a sentence of preamble before the structured-looking part even begins.
# Two calls with the identical prompt can produce meaningfully different shapes
response_a = client.responses.create(model="gpt-5.6-luna", input=extraction_prompt)
response_b = client.responses.create(model="gpt-5.6-luna", input=extraction_prompt)
print(response_a.output_text)
# "Name: Maria Gomez\nAge: 34\nCity: Austin"
print(response_b.output_text)
# "Here's what I found:\n- Name: Maria Gomez\n- Age: 34 years old\n- City: Austin, TX"
Notice the second response adds a leading sentence, switches to bullet points, appends "years old" to the age (breaking the int() conversion the naive parser above depends on), and appends a state abbreviation to the city. None of this is the model "misbehaving" — every one of these responses is a reasonable, human-readable answer to the prompt as asked. The problem is entirely on the parsing side: brittle, format-assuming parsing code has no way to tell a superficial formatting variation from an actual change in the underlying data, and breaks on the former just as readily as it would need to react to the latter.
The Categories of Failure This Creates
It's worth naming the specific ways free-text parsing fails in production, since each has a slightly different signature and a different level of danger.
Silent wrong extraction. The regex matches something, but not the right thing — for instance, a name containing the substring "City" derails a naive city-matching pattern, or a stray digit elsewhere in the text gets picked up as the age instead of the real one. This is the most dangerous failure mode because nothing crashes and no error is raised; the application simply proceeds with subtly incorrect data, and the bug may not surface until someone notices the wrong value much later, possibly after it has already propagated into a database, a report, or a decision made from it.
Loud parsing failure. A regex simply fails to match, age_match is None, and int(age_match.group(1)) raises an AttributeError because NoneType has no .group() method. This is more annoying than the silent case, but strictly safer — it's immediately visible that something went wrong, rather than quietly propagating bad data downstream.
Partial extraction. Some fields parse correctly and others don't, producing a record that is neither cleanly successful nor cleanly failed — often the hardest case to handle gracefully, since "half of this record is trustworthy" doesn't map cleanly onto typical error-handling patterns that expect a clean success/failure boundary.
def demonstrate_failure_modes():
# Silent wrong extraction: this "city" pattern greedily grabs too much
text_1 = "City: Austin, and the person also mentioned City Hall as a landmark."
match = re.search(r"City:\s*(.+)", text_1)
print(match.group(1))
# "Austin, and the person also mentioned City Hall as a landmark."
# Wrong, but no exception is raised — this is the dangerous, silent case.
# Loud parsing failure: no "Age:" label present at all
text_2 = "Maria Gomez is 34 and lives in Austin."
match = re.search(r"Age:\s*(\d+)", text_2)
print(match) # None — and code assuming a match will crash immediately after
demonstrate_failure_modes()
Why "Just Improve the Prompt" Only Gets You So Far
A natural response to this problem is to make the prompt more explicit about the desired format: "Respond ONLY in the exact format: Name: , Age: , City: , with no other text." This genuinely helps — it's a real, valid technique, and Unit 3's discussion of instructions and few-shot examples applies directly here — but it does not fully solve the underlying problem, for two reasons worth being explicit about.
First, it's a request, not a constraint. The model is still generating free-form text token by token; a carefully worded instruction increases the probability that the output matches the requested format, but does not make it structurally impossible for the model to deviate, especially on edge cases, unusual inputs, or longer conversations where earlier instructions carry proportionally less weight than they did at the start (a dynamic Unit 4's discussion of context and compaction touches on from a different angle).
response = client.responses.create(
model="gpt-5.6-luna",
instructions="Respond ONLY in the exact format: Name: <name>, Age: <number>, City: <city>. No other text.",
input="Extract the name, age, and city from: 'The person mentioned they are unsure of their exact age, roughly mid-30s, and recently moved from Austin to Denver.'",
)
print(response.output_text)
# The model may reasonably produce something like:
# "Name: [not stated], Age: mid-30s (approximate), City: Denver (recently moved from Austin)"
This response is arguably a better, more honest answer to a genuinely ambiguous input than a rigidly formatted but misleading one would be — but it also completely breaks any parser expecting a clean integer for age and a single unambiguous city string. The model correctly recognized real ambiguity in the input; the prompt's formatting instruction didn't anticipate that ambiguity and had no mechanism to force a structurally valid answer despite it.
Second, even when the model does follow the requested format faithfully, "an instruction the model usually follows" and "a format the code can structurally rely on" are different guarantees, and production software generally needs the latter. A parsing approach that works 95% of the time and fails unpredictably the other 5% is often worse than a mechanism that is structurally guaranteed to work 100% of the time in a known way — because the 95% case creates a false sense of reliability that the 5% case then violates unpredictably, often in production, often at the worst possible moment.
What "Structured" Actually Means in This Context
The term "structured output," which the rest of this unit builds toward, refers to a fundamentally different approach: rather than asking the model to produce text that looks like structured data and hoping the format holds, you give the API a formal schema — a precise specification of exactly which fields must exist, what type each one must be, and what values are and aren't valid — and the generation process itself is constrained so that its output is guaranteed, by construction, to conform to that schema. This is not a stronger version of prompt-engineering the desired format; it's a structurally different mechanism operating at a different layer, which Lesson 2 of this unit explains in full technical detail.
# A preview of where this unit is headed — full mechanics in Lesson 2
response = client.responses.create(
model="gpt-5.6-luna",
input="Extract the name, age, and city from: 'Maria Gomez, 34, lives in Austin.'",
text={
"format": {
"type": "json_schema",
"name": "person_extraction",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"city": {"type": "string"},
},
"required": ["name", "age", "city"],
"additionalProperties": False,
},
"strict": True,
}
},
)
import json
data = json.loads(response.output_text)
print(data) # {'name': 'Maria Gomez', 'age': 34, 'city': 'Austin'}
The critical difference from everything shown earlier in this lesson: data["age"] is guaranteed to be present and guaranteed to be a JSON number, not a string that might or might not contain "years old" appended to it, not a field that might or might not be present depending on how the model chose to phrase its answer. This guarantee comes from the schema being enforced during generation itself, not from a hopeful instruction asking the model to please format things a certain way.
A Concrete Comparison of the Two Approaches
| Aspect | Free-text + parsing | Structured output (schema-enforced) |
|---|---|---|
| Format guarantee | None — depends on the model happening to follow a requested format | Enforced during generation — output is guaranteed valid per schema |
| Parsing code needed | Custom regex/string logic per use case, brittle to phrasing changes | A single, generic json.loads() call plus your schema definition |
| Failure mode | Silent wrong extraction or crashes on unexpected phrasing | Field types and presence guaranteed; only content correctness varies |
| Handles ambiguous input | May produce free-form hedging text that breaks rigid parsing | Must still produce a schema-valid value — Lesson 4 covers designing schemas for genuinely ambiguous or missing data |
| Maintenance burden | Grows with every new field or edge case encountered in production | Concentrated in one schema definition, versioned and reviewed like code |
This table is the argument this lesson has built toward: free-text parsing asks the model to hope its formatting stays consistent and asks your code to hope it correctly guesses that formatting; structured outputs remove both of those hopes and replace them with an explicit, enforced contract between your application and the model.
When Free-Text Output Is Still the Right Choice
It's worth being precise about scope: nothing in this lesson means free-text output is always wrong. When a response is meant to be read by a person — a chat reply, a written explanation, a long-form document — free text is exactly the right shape, and wrapping it in a rigid schema would be pointless overhead solving a problem that doesn't exist in that context. Structured outputs matter specifically when the consumer of the response is code: a database write, a conditional branch, a downstream API call, a form population. The test worth applying to any given response is simple: will a program read this output, or will a person? If a program will read it, structured outputs (covered starting in Lesson 2) are very likely the right tool; if a person will read it, plain text remains the right choice, and no schema is needed at all.
A Common Intermediate Step: Asking the Model to Produce JSON as Text
Before structured outputs existed as a first-class feature, a common workaround was asking the model to produce JSON-formatted text as its answer, then parsing that text with json.loads() — an improvement over ad hoc regex parsing, but still fundamentally a free-text approach with all of this lesson's failure modes intact, just wearing JSON's syntax as a costume rather than solving the underlying reliability problem.
response = client.responses.create(
model="gpt-5.6-luna",
instructions="Respond with a JSON object with keys name, age, and city. Output only the JSON, nothing else.",
input="Extract the name, age, and city from: 'Maria Gomez, 34, lives in Austin.'",
)
import json
try:
data = json.loads(response.output_text)
except json.JSONDecodeError as e:
print(f"Failed to parse as JSON: {e}")
data = None
This is meaningfully better than the regex approach earlier in this lesson — json.loads() at least enforces that the overall structure is syntactically valid JSON, rather than relying on hand-written pattern matching. But it is still an unenforced request: nothing prevents the model from wrapping the JSON in a markdown code fence ( json ... ), adding a sentence of commentary before or after it, using a different key name than requested ("full_name" instead of "name"), omitting a field it considers uncertain, or nesting the requested keys inside an extra wrapper object. Each of these is a plausible, reasonable-looking response that nonetheless breaks a parser assuming an exact key structure.
# A plausible response that breaks naive JSON parsing despite being "basically JSON"
raw = '```json\n{"name": "Maria Gomez", "age": 34, "city": "Austin"}\n```'
try:
data = json.loads(raw) # raises JSONDecodeError — the code fence isn't valid JSON syntax
except json.JSONDecodeError:
# A common defensive workaround: strip markdown fencing before parsing
cleaned = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
data = json.loads(cleaned)
print(data)
Needing this kind of defensive string-stripping before you can even attempt to parse JSON is itself a symptom of the same underlying issue this lesson has been describing: the format is still fundamentally a request, honored by convention rather than guaranteed by construction, and every convention the model might reasonably follow (or fail to follow) becomes another edge case your parsing code has to anticipate.
A Worked Example With Nested and Optional Data
The failure modes above become sharper once the data being extracted has any real structure to it — a list of items, an optional field, a nested object — rather than three flat, always-present string and number fields. Consider extracting a structured order summary from a free-form customer message.
message = (
"Hi, I'd like to order 2 large coffees and a blueberry muffin. "
"Actually, could you also throw in a bottle of water if you have any? "
"I'll pick it up around 3pm."
)
response = client.responses.create(
model="gpt-5.6-luna",
instructions="Extract the order as a list of items with quantities, and the pickup time if mentioned.",
input=message,
)
print(response.output_text)
A response to this prompt could reasonably take any of several shapes: a numbered list, a sentence describing the items, a table-like block of text, with the conditional "if you have any" water potentially included or omitted depending on how the model weighs its conditional phrasing, and the pickup time expressed as "3pm", "15:00", or "around 3:00 PM". Writing parsing code robust to every one of these variations — for a data shape that isn't even particularly complex — starts to require substantially more defensive logic than the three-field example earlier in this lesson, and the complexity only grows from there for genuinely nested data (an order with per-item modifiers, a multi-address shipment, a form with repeating sections). This scaling problem is a large part of why structured outputs, covered starting in the next lesson, matter more as extraction tasks grow more realistic and complex — hand-written parsing logic that's merely annoying for three flat fields becomes genuinely unmanageable for realistically nested, partially-optional data.
Why This Matters More as Volume Increases
A parsing approach that fails on roughly one request in a hundred might seem tolerable when testing by hand, one request at a time — but production applications rarely process one request at a time. An extraction pipeline processing ten thousand customer messages a day, at even a modest 1% silent-failure rate, produces on the order of a hundred incorrectly extracted records daily, most of which nobody manually reviews before they reach a database, a report, or a decision made from them. This is precisely the scenario structured outputs are built to prevent: not by making the model smarter about the specific extraction task, but by making the format of its answer impossible to get structurally wrong, which shrinks the space of possible failures down to genuine content-level mistakes (extracting the wrong value into a correctly-typed field) rather than format-level ones (a field missing, wrongly typed, or wrapped in unexpected text) — a distinction Lesson 2 develops further once the schema mechanism itself is on the table.
Common Mistakes
Writing parsing code against one observed example of the model's output, rather than testing against several calls, several rephrasings of the prompt, and several edge-case inputs, and discovering only in production that the format isn't as consistent as the single example suggested.
Treating a formatting instruction in the prompt as equivalent to a structural guarantee, and skipping validation of the parsed result entirely, on the assumption that a clearly worded instruction is sufficient on its own.
Using free-text parsing for a response with an obvious, learnable structure (a fixed set of fields, known types) when a schema-based structured output, covered in the rest of this unit, would eliminate the parsing problem at its root rather than requiring ever more defensive parsing code to paper over it.
Not distinguishing between silent wrong extraction and loud parsing failure when designing error handling — code that only checks "did this raise an exception" misses the more dangerous case where a regex matched something plausible-looking but incorrect.
Best Practices
Ask, for any given response, whether a person or a program is the actual consumer of that output, and let the answer decide between free text and a structured, schema-enforced format rather than defaulting to one or the other out of habit.
Treat a prompt-level formatting instruction as a helpful nudge, never as a substitute for an enforced schema, when the downstream code genuinely needs a guarantee rather than a strong tendency.
Prefer raising a clear, immediate error over a parser that silently extracts a plausible-but-wrong value, when structured outputs aren't yet in place and free-text parsing is still what's driving an extraction task.
Treat the rest of this unit's schema-based approach as the default for any new feature that extracts, classifies, or otherwise turns model output into program-consumed data, reserving hand-rolled text parsing for the shrinking set of cases where a schema genuinely isn't practical to define.