Skip to content

Extracting Comparable Sales From a Property Appraisal Report

9 min read · updated August 11, 2026

An appraisal report is mostly narrative, and none of the narrative is what you want. The value lives in the sales comparison grid, which is a table laid out the wrong way round from every table your parser has seen, and which happens to carry enough redundancy to prove your own extraction right or wrong.

The grid is transposed, and that is the whole problem

On the Uniform Residential Appraisal Report — Fannie Mae publishes the form and the Uniform Appraisal Dataset appendix that governs how its fields are filled in — the sales comparison approach is a table whose rows are property features and whose columns are the subject property and three comparable sales. Address, proximity, sale price, price per square foot of gross living area, date of sale, location, site, view, design, quality, age, condition, room count, gross living area, basement, functional utility, heating and cooling, garage, porch and patio: roughly twenty-five feature rows, each one crossing four columns.

Each comparable column is then split into two sub-columns. The left one describes the comparable’s feature; the right one holds the dollar adjustment applied for the difference between that feature and the subject’s. The subject column has no adjustment sub-column, so the header row and the body rows do not have the same number of cells. A table extractor that infers column count from the widest row gets seven columns; one that infers it from the header gets four; the truth is one plus three times two.

This is a coordinate problem before it is a language problem. The reliable approach is to cluster cell bounding boxes by their horizontal position into the seven bands, then read each band top to bottom as one comparable’s description or adjustment vector, rather than reading the page in the order the PDF’s text operators happen to emit. The general version of that argument lives in PDF parsing, in the document table schema page and in merged cell table extraction; what is specific here is that the two sub-columns are semantically different types — one is free text or a code, the other is always a signed currency amount or blank — so a band whose values are 90% currency and 10% prose has been mis-clustered.

Reading a signed adjustment column

The sign convention is fixed and it is the opposite of what people guess. Adjustments are applied to the comparable, to make it resemble the subject. If the comparable has a feature the subject lacks — a third bathroom, a larger lot, a finished basement — the comparable is worth more than the subject on that dimension, and the adjustment is negative. If the subject has something the comparable lacks, the adjustment is positive. A page that stores the absolute value has thrown away the only part of the number that carries meaning.

Negatives appear in at least four typographies on real forms: -5,000, (5,000), - 5,000 with a space, and a minus sign that is a typographic en dash rather than a hyphen. Blank means zero adjustment, and blank is not the same as a written 0 — a blank means the appraiser judged the features equivalent, a zero sometimes means the row was considered and priced at nothing. Keep them distinct in the schema and let the consumer collapse them.

Four identities that must hold

Below the feature rows the form prints, for each comparable, a net adjustment total, a gross adjustment total, and both expressed as percentages of the comparable’s sale price, and then the adjusted sale price. Those printed values are not decoration. They are four independent functions of the same adjustment column, which means you can extract the column, recompute all four, and compare.

net   = sum(adjustments)
gross = sum(abs(adjustments))
net %   = net   / comparable_sale_price
gross % = gross / comparable_sale_price
adjusted_sale_price = comparable_sale_price + net

Worked on a synthetic comparable, with every figure invented for the example: sale price 420,000; site +3,000; gross living area −8,500; garage +6,000; condition −4,000; date of sale +2,500. The net is 3,000 − 8,500 + 6,000 − 4,000 + 2,500 = −1,000, so the adjusted sale price is 419,000. The gross is 3,000 + 8,500 + 6,000 + 4,000 + 2,500 = 24,000, which is 5.7% of 420,000, and the net is 0.2%.

Now break it the way an extractor breaks it. Read the gross living area adjustment as −3,500 instead of −8,500 — a plausible confusion of 8 for 3 on a faxed copy — and the recomputed net becomes +4,000 and the recomputed adjusted price 424,000, against a printed 419,000. The discrepancy is 5,000, exactly twice the digit error, which is itself a hint: a single-figure misread shows up in the net as the difference between the true and read values, and in the gross as the same magnitude, so a mismatch that is equal in net and gross points at a value error while a mismatch that appears only in the net points at a dropped sign.

The percentages catch a case the totals do not. If the extractor also misreads the printed net total in a way consistent with its misread column, the first two identities agree with each other and are both wrong; the two percentages are computed against the sale price, which came from a different row, so they still disagree. This is redundancy worth using rather than a curiosity. Where a document supplies its own arithmetic, a validator built on that arithmetic is worth more than any per-field confidence score, and the two are complementary — see cross-field amount validation for the general form of this check and extraction confidence for why a model’s own certainty is the weaker signal.

Form 1004 and the Uniform Appraisal Dataset are subject to revision, and a redesigned appraisal dataset has been in progress at the government-sponsored enterprises for several years. Treat row labels and code lists as versioned against the form revision printed on the document rather than as constants. Fannie Mae publishes both: singlefamily.fanniemae.com.

UAD codes are data, not prose

Several grid cells are not English. Under the Uniform Appraisal Dataset the condition of a property is recorded as a rating from C1 to C6 and quality of construction as Q1 to Q6, location and view as semicolon-delimited rating-and-factor strings, and the date of sale cell compresses a contract date and a settlement date into a shorthand with letter prefixes. A vision model asked to describe the cell will helpfully expand C3 into a sentence about the property being well maintained, and that expansion is a paraphrase of a controlled value.

Store the cell verbatim as a string, then map it through the code list for the form revision in a separate step you can version and re-run. The rule generalises: any cell whose alphabet is small and fixed should be captured as a code and expanded downstream, never expanded in the prompt. It also gives you a cheap validity test, because a condition rating that is not one of six values is a read error rather than an unusual property.

Where real reports break the extraction

  • More than three comparables. Additional comps go on a continuation page that repeats the feature column, so the same feature labels appear twice in one document. Key comparables by page plus column index, not by label — the general case is column misalignment across pages.
  • Adjustments explained in the addendum. The grid says −8,500 and a paragraph three pages later says it is 120 square feet at 70 dollars a foot. That reconciliation is worth capturing, and it is a second arithmetic check when it is present.
  • Price per square foot as a derived row. Sale price divided by gross living area is printed and can be recomputed. When it disagrees, one of the two inputs was misread and the ratio tells you which.
  • Scanned and annotated copies. Review copies carry handwriting over the grid. Ink crossing a cell boundary is the common cause of an adjustment appearing in the wrong comparable’s band.
  • A zero-adjustment row dropped entirely. None of the four identities notices, because the row contributes nothing to either total. That is acceptable: it carries no money. Do not build a validator that fails on it.