Marketing

Writing a Revision Prompt for Structured Data, Not Prose

Antonio Evans10 min read

A split-screen illustration: on the left, a messy paragraph of prose with arrows pointing to a spreadsheet, and on the right, a clean table with four columns labeled Row ID, Field, Old Value, New Value.
Key points

Key points

  1. 1A revision prompt for a table must name the row identifier, the field, the old value, the new value and the operation, so every change can be checked and applied on its own.
  2. 2Prose prompts fail on structured data because a rewritten table gives you no way to tell which cells moved, which were dropped and which were silently reworded.
  3. 3Asking the model to return only changed rows, in a fixed schema such as JSON, turns an edit into a reviewable diff rather than a second draft you have to compare by eye.
  4. 4A deterministic apply step that matches on identifier and field, plus a check that the old value matches the cell, is what makes the change log safe enough to run automatically.

Why a prose prompt fails on a table

Ask a model to "improve this table" and you get a table back. It looks plausible, the columns are still there, and the row count is roughly right. What you cannot see is what happened. A price may have been reformatted, a description trimmed, a category renamed to a synonym, and a row that was already fine may have been quietly rewritten for no reason.

The failure has three parts. First, the output is a whole new artifact, so reviewing it means comparing two tables cell by cell, which is exactly the work you were trying to avoid. Second, the model has latitude to change things you never mentioned, because a prose instruction sets a direction rather than a boundary. Third, there is no record of intent, so when a value downstream looks wrong you cannot tell whether the source was wrong or the revision introduced the error.

Structured output exists precisely because free-form text is hard to parse and integrate reliably; the standard account of this is that you define a schema with named fields and types so a program can consume the result without cleanup (see this guide to structured output and JSON mode). A revision prompt is structured output aimed at a very specific job: describing a delta.

There is a second, quieter reason. Models are trained to produce polished, finished prose, and that habit carries over into anything that looks like writing, including the cells of a table. The result is text that reads well and has drifted from the source, sometimes adding hedges or smoothing a fact into something vaguer (see this analysis of how models hedge by default and how to cut it). Citations and exact figures do not improve under polishing, so you want a format where the only legal content is a specified change.

The five fields every change record needs

A change record is a small object. Keep it to five fields and it stays easy to review and easy to apply.

FieldPurposeExample
row_idPoints at the exact row, using a key that never changesSKU-1042
fieldNames the column using its exact header or keyunit_price
old_valueThe value read from the source, quoted as it appears"19.99"
new_valueThe replacement value, in the column's own type"24.99"
opWhat kind of change this isset, clear, add_row

Two rules make this work in practice.

The first is that old_value is not decoration. It is your guard. When the apply script reaches the row, it compares the current value with old_value. If they do not match, the table has changed since the prompt was written and the change is rejected rather than applied on top of newer data. This is the same idea as optimistic concurrency control, and it costs one line of code.

The second is that identifiers must be stable and unique. Row numbers are a bad choice, because deleting a row renumbers everything below it and the whole change list points at the wrong records. Use whatever key your data already has, such as an order ID, a SKU or a slug, and if the table has none, add one before you start.

For the operations, keep the vocabulary tiny and define it in the prompt: set replaces a value, clear empties a field, add_row introduces a record that did not exist. Anything the model cannot express with those words it should not do.

Writing the prompt itself

A revision prompt has four parts: the schema, the table, the instruction, and the output contract. Put them in that order and the model has no ambiguity about what a valid answer looks like.

Here is a working template. The table goes in as CSV so row and column boundaries are unambiguous, and the identifiers come from the first column.

You are revising a structured table. Return a list of changes, not a table.

SCHEMA
Columns, in order: sku, name, category, unit_price, in_stock
Primary key: sku (unique, never changes)

TASK
Flag any row where unit_price is below 5.00 as a pricing error by
clearing unit_price and setting category to "REVIEW". Do not change
any other column. Do not reword names. Do not add or remove rows.

OUTPUT
Return JSON only, no commentary and no markdown fences:
{"changes": [{"row_id": "...", "field": "...", "old_value": "...",
"new_value": "...", "op": "set|clear|add_row"}]}

Leave unchanged rows out entirely. If a row needs no change, it does
not appear. If nothing needs to change, return {"changes": []}.
Quote every value as a string exactly as it appears in the source.
Put one record per field per row; a row with two edits produces two
records.

TABLE (CSV)
sku,name,category,unit_price,in_stock
SKU-1001,Steel Mug,drinkware,19.99,true
SKU-1002,Cedar Board,outdoor,4.50,false
SKU-1003,Glass Lid,kitchen,7.25,true

For that input the correct answer is a single record: SKU-1002, field category, old outdoor, new REVIEW, op set, plus one more for unit_price, old 4.50, new empty, op clear. Two changes, two records, one row.

Notice what the prompt does not say. It does not say "make the table better", because that hands the model a licence. It says what to change, what not to touch, and what a valid answer looks like. The instruction to quote values as strings is deliberate: it keeps 4.50 from becoming the number 4.5, which matters when your apply step compares old values as text.

The reason to be this explicit is that a schema in the prompt and a schema in the API are not the same guarantee. Native structured output and JSON modes reduce the chance of malformed responses, but schema design in the prompt is still what makes the fields mean what you think they mean, as practitioners discussing structured output on the OpenAI developer forum point out when they ask whether field names and descriptions do any work. They do. Name a field old_value and the model tends to put the old value in it.

Choosing the output format: JSON, CSV or a patch

JSON and CSV both work. The choice is about who reads the result.

FormatBest forWatch out for
JSON array of change objectsProgrammatic apply, nested or typed valuesNeeds a parser; quoting errors break the whole response
CSV with the five columnsHuman review in a spreadsheet, quick sign-offCommas and quotes inside values; empty cells can mean "no change" or "clear"
SQL UPDATE statementsApplying directly to a databaseA model that writes SQL can also write the wrong WHERE clause
Patch or diff textVersion-controlled filesFragile when line numbers shift

For most teams the safest pattern is JSON for the machine and a rendered table for the reviewer. Generate the review view from the JSON rather than asking the model for both, so the two cannot disagree.

If you do ask for CSV, add one line to the prompt: "Use an empty new_value cell only with op clear, and quote any value that contains a comma." That single sentence removes the most common parsing failure, where Steel Mug, large silently splits into two columns.

Avoid letting the model emit SQL unless you are prepared to parse it. The classic risk in any generated update statement is a missing or over-broad filter, and a change record with an explicit row_id is far easier to validate than a WHERE clause you have to read carefully.

Reviewing the change list before you apply it

Review is the point of this whole exercise, so make the review view do the work. A change list should show, for each record, the row, the field, the old value, the new value and a status. Sort by row_id, not by the order the model produced, so a reviewer looking at one record sees all of its edits together.

Run four checks before a human ever looks at it:

  1. Schema check. Every record has all five fields, op is one of the allowed values, and row_id exists in the source table.
  2. Field check. Every field is a real column name, matched exactly, including case.
  3. Stale check. The current cell equals old_value. If not, the record is rejected and flagged.
  4. Scope check. Count the changed rows against the instruction. If you asked for rows under 5.00 and the list contains forty records across the whole table, the prompt drifted and the whole batch should go back.

The fourth check is the one people skip, and it is the one that catches a model that decided to be helpful. A simple assertion on the number of distinct row_id values, or on the set of fields touched, will catch most of it. If you told the model to only change unit_price and category, any record naming name is a bug.

This is also where a spreadsheet earns its keep. A reviewer can filter to op = clear, check that every cleared price is genuinely suspicious, and approve in a couple of minutes. That is a fundamentally different task from reading two tables side by side.

Applying the changes without a full retype

The apply step should be boring. It reads the change list, checks each record against the live table, writes the ones that pass, and reports the ones that fail. Nothing in it should involve regenerating a cell.

import csv

def apply_changes(rows, changes):
    index = {r["sku"]: r for r in rows}
    applied, rejected = [], []
    for c in changes:
        row = index.get(c["row_id"])
        if row is None:
            rejected.append((c, "unknown row"))
        elif row.get(c["field"]) != c["old_value"]:
            rejected.append((c, "stale old_value"))
        elif c["op"] == "set":
            row[c["field"]] = c["new_value"]
            applied.append(c)
        elif c["op"] == "clear":
            row[c["field"]] = ""
            applied.append(c)
        else:
            rejected.append((c, "unsupported op"))
    return rows, applied, rejected

Three properties make this safe. It is idempotent in effect for anything already applied, because the old value will no longer match and the record is rejected rather than applied twice. It fails closed, so an unknown row or field never reaches the data. And it produces a report, so you can show what changed and what did not.

If your data lives in a warehouse, the same logic can run as a join: stage the change list as a temporary table keyed on row_id and field, join it to the target on the key, and update only where the source value matches old_value. That is the standard shape of a safe bulk update, and it keeps the audit trail inside the database.

Worth saying plainly: the change list is a schema, and schemas need governing. If you keep a changelog of revisions, decide which fields consumers may rely on and treat renames as breaking changes, the same way teams do in streaming systems, where compatibility modes are enforced at the schema registry before a change reaches production (see these schema evolution practices). A field named unit_price that quietly starts holding a string like "call us" is a schema change, not an edit.

Worked example with numbers

Suppose you run a product table with 4,000 rows and you need to fix pricing after a supplier increase. Only 620 rows are affected. Two approaches:

A full rewrite means the model returns 4,000 rows, including 3,380 it had no business touching. At an average of 90 tokens per row for input and output combined, that is roughly 720,000 tokens of traffic, and a reviewer has to spot 620 changes inside 4,000 rows.

A change list means the model reads the table once (about 360,000 input tokens at 90 per row) and writes 620 records, each around 30 tokens, for roughly 18,600 output tokens. The reviewer sees 620 lines, every one of which is a change by definition, and the apply script touches exactly 620 cells.

The saving in output is large, but the real gain is the review. Six hundred lines with old and new values can be skimmed in a spreadsheet. Four thousand rows cannot. The same arithmetic applies to smaller jobs: a 40-row table with 3 edits produces 3 records, and a reviewer can sign off in seconds.

Consider the risk side too. In a rewrite, a single hallucinated row is invisible among 4,000. In a change list, an extra record is a line that does not belong, and the scope check flags it before anyone reads it.

When you need more than a change list

A change list is the right output when the task is a bounded edit to known fields. It is the wrong output in three cases.

When the task is enrichment, meaning you want new information for every row rather than edits to existing cells, the natural shape is a per-row prompt that fills a column, which is how in-table AI columns work: you write a plain-language prompt, preview it on a sample of rows, then run it across the table and cache results so only changed inputs are recomputed (see Sigma's description of AI columns). The preview step there is the same review discipline, applied to a whole column instead of a change list.

When the values must be typed rather than text, for example multi-select categories stored as separate records, think about how your system stores multiple values before you ask a model to produce them. Fields that hold a set of values behave differently from single-value fields when you write to them: some configurations replace the existing values on import, others append, and the wrong choice corrupts records silently (see this documentation on fields that store multiple values). A change record that says set is unambiguous only if you know which of those behaviors the target field has.

When the change touches the meaning of a column rather than its contents, stop. Renaming a column, changing its type in a non-widening way, or changing what it measures are breaking changes that need a migration, not a revision prompt, and the usual classification separates those from backward-compatible additions like a new optional column with a default (see this schema evolution prompt library).

One more rule covers the awkward middle: if the model wants to change the header row, it should say so as a change with the field set to the old header name and the new value set to the new name, so the structural edit appears in the same review queue as everything else.

If you build change lists by hand for a while, the natural next step is to run the whole loop through one interface rather than stitching together a chat window, a parser and a script. Quanta Inference gives you text models through one OpenAI-compatible API, which is enough for the prompt-and-parse part, at https://quantaintelligence.ai/inference.

FAQ

FAQ

How is a revision prompt different from asking the model to rewrite a table?

A rewrite returns a new table, so you have to compare it with the old one to find what changed. A revision prompt returns only the changes, each naming its row, field, old value and new value, so the review is a list you can scan and the apply step writes individual cells. The difference is not politeness of phrasing, it is the shape of the output.

What should I do when the source table has no unique key?

Add one before prompting. Row numbers are not stable, because deleting or sorting a row moves every identifier below it, and the change list then points at the wrong records. If the data has no natural key such as an order ID or SKU, generate a column of stable identifiers and include it in the CSV you pass to the model.

How do I stop the model from making changes I did not ask for?

State the scope as a rule, not a sentiment: which column or columns may change, and that all other cells must be left alone. Then enforce it after the fact with a scope check that rejects any record naming a field you did not authorise, and an assertion on how many distinct rows were touched. Prompts reduce drift; the check catches it.

Should I ask for JSON or CSV?

Ask for JSON when the result feeds a script, and CSV when a person will review it in a spreadsheet, but generate the review view yourself from the JSON rather than trusting two formats to agree. Whichever you choose, define how empty values and commas inside values are handled, because those are the two places where a change list most often breaks.

What happens if the table changed after the prompt was written?

The old value check catches it. Each record carries the value the model saw, and the apply step compares that with the current cell; a mismatch means the record is rejected and reported rather than written on top of newer data. This is why old value is a required field and not a convenience, and why the apply step should fail closed and produce a report of rejected records.

Sources

Sources

  1. Structured Output & JSON Mode in LLMs: When to Use, Examples
  2. Hedging Words in AI Text: Why 'It's Important to Note' Flags | Leap
  3. How to effectively prompt for Structured Output
  4. Schema Evolution: 8 Kafka Best Practices
  5. Introducing AI Columns
  6. Fields that store multiple values from a prompt list
  7. Schema Evolution Strategy AI Prompt - Data Engineer
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles