Reporting Data Quality to Non-Technical Stakeholders

The audience for a quality report is not trying to understand your rule set. They are deciding whether to sign off a release, fund a remediation programme, accept a supplier’s delivery, or answer a regulator. A report that opens with defect rates asks them to do the translation themselves, and they will translate it into “the data team says there are some problems” — which funds nothing and decides nothing. This guide covers the translation: leading with fitness for purpose, expressing every defect class as an operational consequence, showing trend with the context that explains it, and naming the owner and the specific ask. It is the executive view of the metric set defined in Spatial Data Quality Metrics and Reporting.

Prerequisites

  • A declared purpose per layer and the fitness thresholds attached to it. Without those, “fit for purpose” is an opinion.
  • A rule-to-consequence translation table, maintained alongside the rule catalogue. This is the artefact that makes the whole report possible and it is almost always missing.
  • Trend data — at least three months of the daily aggregate, so movement can be shown rather than asserted.
  • A named owner per layer, from Assigning Spatial Data Ownership with a RACI Matrix.
  • A stable link target for each layer’s findings, so every figure in the report is checkable.

Step-by-Step Procedure

Step 1 — Lead with the fitness decision

# report/step1_fitness.py
FITNESS = {
    # layer -> use -> (max blocker rate per 1k, required clean share)
    "parcels": {
        "tax_assessment":     (0.5, 0.97),
        "emergency_dispatch": (0.0, 0.995),
    },
    "road_network": {
        "routing":            (0.2, 0.99),
        "asset_reporting":    (2.0, 0.95),
    },
}


def fitness_statements(metrics: dict) -> list[dict]:
    """One plain-language line per layer and declared use."""
    out = []
    for layer, uses in FITNESS.items():
        m = metrics[layer]
        for use, (max_blocker, min_clean) in uses.items():
            ok = m["blocker_per_1k"] <= max_blocker and m["clean_share"] >= min_clean
            out.append({
                "layer": layer,
                "use": use.replace("_", " "),
                "fit": ok,
                "line": (f"{layer.replace('_', ' ').title()} is "
                         f"{'fit' if ok else 'NOT fit'} for {use.replace('_', ' ')}"),
                "because": (
                    "" if ok else
                    f"blocking defects {m['blocker_per_1k']:.2f} per 1,000 "
                    f"(limit {max_blocker}); "
                    f"{m['clean_share']:.1%} of features fully clean "
                    f"(need {min_clean:.1%})"),
            })
    return out

Verification: read the generated lines aloud. If a line needs a glossary, it is not a fitness statement yet. The because clause carries the numbers so the claim is falsifiable without the numbers being the headline.

The translation, from rule to decisionChain of four translation steps: a rule identifier becomes a plain-language defect, the defect becomes an operational consequence, the consequence becomes a fitness statement, and the fitness statement becomes a decision request with a named owner.TOPO_OVERLAP_001rule identifierTwo parcels claimthe same groundplain languageDuplicate billing;boundary disputesconsequenceNot fit fortax assessmentfitness statementApprove aremediation sprintdecision request
Four translations separate a rule identifier from something an executive can act on.

Step 2 — Translate every defect class into a consequence

# report/step2_translate.py
CONSEQUENCE = {
    "TOPO_OVERLAP_001": {
        "plain": "Two parcels claim the same ground",
        "impact": "duplicate or disputed billing; boundary disputes at registration",
        "audience": "finance, registry",
    },
    "TOPO_GAP_001": {
        "plain": "Land not covered by any parcel",
        "impact": "unbilled area; gaps in coverage statistics",
        "audience": "finance",
    },
    "GEOM_VALID_001": {
        "plain": "Parcel outline crosses itself",
        "impact": "area calculations refuse to run; the parcel is excluded from reports",
        "audience": "operations",
    },
    "CRS_001": {
        "plain": "Layer supplied in the wrong coordinate system",
        "impact": "everything is in the wrong place; distances are meaningless",
        "audience": "everyone",
    },
    "TEMPORAL_STALE_001": {
        "plain": "Records not updated within the agreed window",
        "impact": "decisions made on out-of-date information",
        "audience": "operations, compliance",
    },
}


def summarise_consequences(findings_by_rule: dict[str, int]) -> list[dict]:
    rows = []
    for rule, count in sorted(findings_by_rule.items(), key=lambda kv: -kv[1]):
        meta = CONSEQUENCE.get(rule)
        if not meta:
            continue                      # unmapped rules stay out of the executive view
        rows.append({"count": count, **meta})
    return rows

Verification: every rule that can reach a blocker severity must have an entry. A blocker with no consequence text cannot be explained to the person being asked to fund fixing it, which in practice means it will not be funded.

Step 3 — Show trend with the context that explains it

# report/step3_trend.py
import pandas as pd


def trend_narrative(daily: pd.DataFrame, layer: str, annotations: pd.DataFrame) -> str:
    """A two-sentence description of the last quarter, with causes attached."""
    g = daily[daily["layer_id"] == layer].sort_values("run_date")
    recent, prior = g.tail(30)["blocker_per_1k"].mean(), g.head(30)["blocker_per_1k"].mean()
    change = (recent - prior) / prior * 100 if prior else 0.0

    direction = "improved" if change < -5 else "worsened" if change > 5 else "held steady"
    notes = annotations[(annotations["layer_id"] == layer)
                        & (annotations["at"] >= g["run_date"].min())]
    context = ("; ".join(notes["text"].tolist())
               if not notes.empty else "no rule or scope changes in this period")

    return (f"Blocking defects have {direction} over the quarter "
            f"({prior:.2f}{recent:.2f} per 1,000 features). Context: {context}.")

Verification: the sentence must be true even when the news is good. Reports that only narrate bad news are read as advocacy and discounted accordingly; a quarter where everything improved should say so in the same format.

Step 4 — Name the owner and the single ask

# report/step4_asks.py
OWNERS = {"parcels": "Land Registry Data Steward",
          "road_network": "Highways Asset Manager"}


def action_items(fitness: list[dict], consequences: list[dict], layer: str) -> list[dict]:
    """Each red item becomes one owner, one ask, one deadline."""
    items = []
    for f in fitness:
        if f["layer"] != layer or f["fit"]:
            continue
        top = consequences[0] if consequences else None
        items.append({
            "owner": OWNERS.get(layer, "unassigned"),
            "issue": f"{f['layer']} not fit for {f['use']}",
            "primary_cause": top["plain"] if top else "multiple",
            "ask": ("Approve a two-week remediation sprint against the "
                    f"{top['plain'].lower()} backlog" if top else "Assign an owner"),
            "if_declined": ("The layer continues to be used for "
                            f"{f['use']} with known defects; risk accepted by the owner"),
        })
    return items

Verification: the if_declined field is the one that changes meetings. Presenting a problem without stating what happens if nothing is done invites deferral; stating it converts inaction into an explicit, recorded decision.

Step 5 — Assemble, and keep the detail one click away

# report/step5_render.py
from datetime import date


def render_markdown(layer: str, fitness: list[dict], narrative: str,
                    consequences: list[dict], actions: list[dict],
                    findings_url: str) -> str:
    lines = [f"## {layer.replace('_', ' ').title()} — quality summary, {date.today():%B %Y}", ""]

    for f in (x for x in fitness if x["layer"] == layer):
        mark = "✅" if f["fit"] else "⚠️"
        lines.append(f"- {mark} **{f['line']}**" + (f" — {f['because']}" if f["because"] else ""))
    lines += ["", narrative, "", "**What the defects mean**", ""]

    for c in consequences[:4]:
        lines.append(f"- {c['count']:,} × {c['plain'].lower()}{c['impact']}")

    if actions:
        lines += ["", "**Decisions requested**", ""]
        for a in actions:
            lines.append(f"- {a['owner']}: {a['ask']}")
            lines.append(f"  - If not approved: {a['if_declined']}")

    lines += ["", f"Every figure above links to the underlying checks: {findings_url}", ""]
    return "\n".join(lines)

Verification: the whole rendered report for one layer should fit on a single screen. If it does not, the detail that does not fit belongs behind the link, not in the body.

Interpreting Results

The report succeeds or fails on how the meeting goes, and the failure modes are recognisable:

What the meeting tells you about the reportGrid of five questions asked in a review meeting, what each reveals about the report, and the fix.What it revealsFix"So is it usable?"fitness statement was buriedlead with it"What is a topology error?"rule names leaked into the bodyextend the consequence table"Is this normal?"a snapshot with no trendalways show the quarter"Whose problem is this?"no named ownerowner per line"Can we see the errors?"detail omitted rather than linkedlink every figureThe report succeeds or fails on how the meeting goes; each of these questions names a specific structural defect.
Five questions, five diagnoses — the meeting is the test suite for the report.
What happens in the meeting What it says about the report Fix
“So is it usable or not?” The fitness statement was buried Lead with it
“What does a topology error mean?” Rule names leaked into the body Extend the consequence table
“Is this normal?” A snapshot with no trend Always show the quarter
“Whose problem is this?” No named owner Owner per line, no exceptions
“Can we see the actual errors?” Detail was omitted rather than linked Link every figure
Discussion becomes about the score The score was the headline Demote it below the fitness statement

A useful test before sending: can a reader who skips every number still take the correct action? If the prose alone conveys the decision, the numbers are doing their proper job of supporting it rather than carrying it.

Gotchas & Edge Cases

Percentages without a base sound better than they are. “99.6% clean” over 800,000 parcels leaves 3,200 defective parcels. Give both: the percentage for scale, the count for consequence.

What the audience retains, by report structureBar chart comparing recall of the key decision after a review meeting for four report structures: fitness statement first 88 percent, consequences before metrics 71, score-led reporting 34, and a defect-count table 12 percent.Fitness statement first88% recall the decisionConsequences before metrics71%Score-led34% — discussion moves to the scoreDefect-count table12% — read as "some problems"Indicative figures from post-meeting recall of the requested decision across four report formats in one programme. The ordering is the reproducible part.
Structure decides what survives the meeting — the numbers are the same in all four reports.

“Blocker” is jargon. Internally it is a routing decision; externally it means nothing. Translate it to “stops the process” or name the process it stops.

A red line every month becomes wallpaper. If a layer has been unfit for six consecutive reports, the report is no longer the problem — escalate through governance rather than repeating the slide.

Improvements need to be attributed. When a rate falls because a remediation sprint worked, say so, with the sprint named. Unattributed improvement is assumed to be noise or measurement change, and the team that did the work gets no credit for it.

Never mix rule versions across a trend without saying so. A tightened rule shifts the trend; an unannotated shift will be read as a data regression and someone will be asked to explain a problem that does not exist.

Do not present raw finding counts as the top line. They move with dataset size, and the first person to notice will discount everything else in the report.

When to Escalate

  • A layer unfit for a statutory or safety-critical use goes to the accountable executive immediately, outside the reporting cycle. The monthly report is not the right vehicle for that.
  • Repeated declined remediation asks should be recorded as accepted risk with a named accepter, following the exception process in Data Stewardship Roles and Responsibilities. A decision not to act is a decision, and it needs an owner.
  • Persistent supplier-side defects belong in the contract conversation, with the trend chart as evidence, rather than in the internal quality report.
  • Requests to change how a metric is calculated after seeing an unfavourable result should route to the metric definition review rather than being handled ad hoc — that is exactly the moment a definition needs a change-control process rather than a conversation.

Frequently Asked Questions

What should the first line of the report say?

Whether the data can be used for what it is used for. "The parcel layer is fit for tax assessment and not currently fit for emergency dispatch" is a first line; "0.4% blocker rate" is not. Executives are being asked to make resourcing decisions, and the fitness statement is the only form of the information that maps onto one.

How do I express a topology defect to a non-specialist?

By its consequence. "Fourteen parcels overlap, so two owners are billed for the same land" lands; "ST_Overlaps violations" does not. Keep a translation table from rule to consequence and maintain it as carefully as the rules themselves — it is the interface between the pipeline and everyone who funds it.

Should the report include a single quality score?

One score per layer is helpful for trend, provided it is presented with what it is made of and never as the headline. The headline is the fitness statement; the score is supporting evidence. A report led by a score invites a discussion about the score rather than about the data.

How much technical detail belongs in the report?

None in the body, all of it one link away. The credibility of a non-technical report depends on somebody technical being able to check it — so every figure links to the findings that produced it, and the report says so explicitly. Detail in the body loses the audience; detail that cannot be reached loses the sceptics.


Related

Back to Spatial Data Quality Metrics and Reporting