Skip to main content
Learning Center
Workflow Automation

Process Everyday Data

Summary Reports Someone Actually Reads at 8am

Turning the accepted records from a run into a report that carries what was read, when, and from where, alongside a reconciliation line that says whether the numbers actually add up, in an order fixed enough that two reports can be diffed line by line.

Lesson 9 of 18 in the recommended order · About 25 min (estimate)

On this page
Workflow automation glossary — terms and common confusions
Dry run
A run that reports every change it would make and makes none of them.

A dry run that still writes a log file, sends a message, or creates a folder is not a dry run.

Idempotent
Running it again leaves the same result as running it once.

"It did not crash the second time" is not the same property; check what the second run changed.

Run record
The stored facts about one execution: when it started, what it read, what it changed, how it ended.

A run record is not the log. The log is prose for a person; the record is data the next run reads.

Transient failure
A failure that a later identical attempt could succeed at, such as a timeout.

A rejected record and a dropped connection are not the same failure, and retrying the first one forever is a bug.

Backoff
Waiting longer between successive retries instead of retrying immediately.

Backoff without a maximum attempt count is an unbounded loop with a politeness delay.

Request budget
A hard cap on how many requests one run may make, checked before each request.

A page limit is not a budget if a retry can make extra requests the limit never counts.

Quarantine
Setting aside a record a run could not process, with the reason, so the run continues.

Quarantine is not "skip". A skipped record leaves no trace; a quarantined one is countable and reviewable.

Reconciliation
Showing that the counts in a report add up: read equals processed plus quarantined plus rejected.

A report whose totals cannot be reconciled is a summary of what the code believed, not of what happened.

Trigger
The event or time that causes a run to start.

Scheduling a trigger is a decision made on a machine, not something a browser lesson can install for you.

Heartbeat
A signal a healthy run emits, whose absence is itself the alert.

Alerting only on errors cannot detect the job that stopped running at all, which is the most common outage.

Staleness
How old the most recent successful run is, compared with how old it is allowed to be.

A green last run is not freshness. Ask when it ran, not whether it passed.

Atomic replace
Writing output to a temporary name and renaming it into place in one step.

A reader never sees a half-written file; an interrupted run leaves the previous output intact.

Transport
The injected callable that actually performs a request, separate from the client that interprets it.

A client that builds its own connection cannot be tested without a network, which is why the seam exists.

In-memory filesystem
The filesystem this course’s browser exercises operate on, which lives only inside the tab.

It behaves like a filesystem and is not your disk: nothing an exercise writes exists after the run ends.

Outcome

By the end of this lesson you can take the accepted records from a run, aggregate them into totals a reader can act on, and build a report that also says what was read, when, and from where, closing with a line that states plainly whether the run's own numbers reconcile - all in an order fixed enough that today's report and yesterday's can be compared line by line.

Why it matters

The previous two lessons produced accepted records, quarantined ones, and counts that reconcile between them. None of that is useful yet to the person who has to act on it, because none of it has been turned into something a person reads. A list of Python dictionaries is not a report; it is the material a report is built from.

A report earns the word by doing three things a bare printout does not. It says what it is a report of - which source, read at what time - so a reader six weeks from now can tell this Tuesday's numbers from last Tuesday's without guessing from a filename. It aggregates, because "province: 812, province: 4, province: 91" read one record at a time tells a reader nothing that "north: 907" does not tell them faster. And it states, in words, whether its own arithmetic works - because a total that was computed from a record silently dropped somewhere upstream looks exactly like a total computed correctly, and the only way to tell them apart is to check.

That third point is the one this lesson exists for. A report is generated by code, and code has beliefs about what happened that are not always the same as what happened. A report that never checks its own reconciliation is a summary of what the code believed the run did - which is a different thing from a summary of what the run actually did, and the difference is invisible until the morning it is not.

The other property worth building in from the start is determinism. Two runs over related data - this week's shipments and last week's - should produce reports whose lines land in the same order for the same reasons, so a person, or a diff tool, can tell a real change from noise in how a dictionary happened to iterate.

Concept

A report is a list of lines, built the same way a plan of changes was in an earlier module: as data, before anything is printed. Building the whole thing first, then printing it, is what makes it possible to test - a test can inspect the list of lines directly, without capturing standard output.

Every report opens with what it is a report of. At minimum: the source it read, and when it ran. Six weeks from now, a report with no source line and no timestamp is indistinguishable from any other run over any other data, and a reader has no way to know whether they are looking at today's numbers or an old one still open in a tab.

State the counts, then check them against each other. read, accepted, and quarantined are three numbers the previous lesson already produces. The report's job is to print all three and then compute reconciled: read == accepted + quarantined directly from them, rather than assuming it and printing True because the code that got this far presumably worked. A report that skips this line is claiming success by omission; one that includes it is making a checkable claim.

Aggregate from the accepted records, not from a count carried alongside them. len(accepted_records) and a accepted_count variable that was set somewhere else can drift apart the moment anything between the two touches one and not the other. The records list is the evidence; a count that is not derived from it, at the moment the report is built, is a belief about the evidence rather than a reading of it.

Sort before you print, every time, for the same reason a folder survey did. A dictionary built by grouping records preserves whatever order they happened to arrive in, and that order is not the report's to decide - it is an accident of which record came first in the batch. Reporting the totals in a fixed, sorted order is what makes two reports over related runs comparable: the line for a given region is on the same line number in both, and a difference between them is a real difference in the numbers, not a difference in how a dictionary iterated.

Join a sorted breakdown into one line rather than printing one line per item. ", ".join(f"{name}={total}" for name in sorted(totals)) puts the whole ordering decision into a single string a test - or a person - can compare directly against what it should be. Separate print statements let the lines land in whatever order the loop produced them, and nothing then confirms they were in the right order rather than merely all present.

Read the code

Accepted survey responses from one run, aggregated and reported:

RUN = {
    "source": "weekly-survey.json",
    "run_at": "2026-09-07T08:00:00Z",
    "read_count": 6,
    "quarantined_count": 1,
    "responses": [
        {"id": "p1", "team": "sales", "score": 4},
        {"id": "p2", "team": "support", "score": 5},
        {"id": "p3", "team": "sales", "score": 3},
        {"id": "p4", "team": "support", "score": 4},
        {"id": "p5", "team": "engineering", "score": 5},
    ],
}


def aggregate_by_team(responses):
    totals = {}
    for response in responses:
        totals[response["team"]] = totals.get(response["team"], 0) + response["score"]
    return totals


def build_report(run):
    lines = [f"source: {run['source']}", f"run at: {run['run_at']}"]
    accepted = run["responses"]
    lines.append(f"read: {run['read_count']}")
    lines.append(f"accepted: {len(accepted)}")
    lines.append(f"quarantined: {run['quarantined_count']}")
    reconciled = run["read_count"] == len(accepted) + run["quarantined_count"]
    lines.append(f"reconciled: {reconciled}")
    totals = aggregate_by_team(accepted)
    breakdown = ", ".join(f"{name}={totals[name]}" for name in sorted(totals))
    lines.append(f"teams: {breakdown}")
    return lines


for line in build_report(RUN):
    print(line)

aggregate_by_team builds its dictionary in whatever order teams first appear in responses - sales first here - which is exactly why build_report reads it back out through sorted(totals) rather than iterating the dictionary directly.

reconciled is computed from run["read_count"], len(accepted), and run["quarantined_count"] at the moment the report is built, not copied from anywhere else - if any one of those three numbers were wrong, this line would be the thing that says so.

Predict the output

Predict every line.

Check your prediction
source: weekly-survey.json
run at: 2026-09-07T08:00:00Z
read: 6
accepted: 5
quarantined: 1
reconciled: True
teams: engineering=5, sales=7, support=9

Five responses were accepted; read_count of 6 and quarantined_count of 1 add up to 6, so reconciled is True. sales totals 4 + 3 = 7, support totals 5 + 4 = 9, engineering totals 5. Alphabetical order puts engineering first even though it was the last team to appear in the data.

Modify the code

Replace for name in sorted(totals) with for name in totals, reading the dictionary in whatever order it was built rather than sorting it.

What changes, and why
teams: sales=7, support=9, engineering=5

The numbers are identical - sales=7, support=9, engineering=5 are still exactly right. Only the order changed, to the order the teams first appeared in responses: sales, then support, then engineering.

This is worth taking seriously precisely because nothing is wrong with any individual number. Run this same report next week over a survey where engineering happens to answer first, and the line becomes teams: engineering=5, sales=7, support=9 - a different line, for data that may not actually differ in any way that matters. A person or a diff tool comparing this week's report against last week's now has to work out whether teams: changed because the totals changed or because of the order responses happened to arrive in, and there is no way to tell from the line alone. Sorting is not a cosmetic choice; it is what makes the line mean the same thing every time the same totals occur.

Debug the bug

An assistant was asked to "write a summary report for this batch". It produced this.

def summarise(run, accepted_count):
    print(f"Report for {run['source']}")
    print(f"Processed {accepted_count} of {run['read_count']} records successfully")
    totals = {}
    for record in run["responses"]:
        totals[record["team"]] = totals.get(record["team"], 0) + record["score"]
    for name, total in totals.items():
        print(f"{name}: {total}")
What's actually wrong

It runs, and it produces a plausible-looking report, and it has three defects that would each be invisible to somebody reading the output without also reading the run that produced it.

  1. accepted_count is a parameter, not a value read from run. Nothing checks that it actually equals len(run["responses"]). If whatever calls summarise passes a stale or miscounted value, the report prints it with total confidence and no way for a reader to catch the discrepancy - the report is only as honest as a number it never itself verified.
  2. There is no reconciliation line at all. "Processed 5 of 6" sounds like an accounting of what happened to the sixth record, and it is not one - there is no quarantined_count anywhere in this function, so a reader cannot tell whether the sixth record was quarantined, silently dropped, or never existed.
  3. The team totals print in whatever order the dictionary happened to build them in, for the reason the ModifyTheCode section above just demonstrated - which makes this report unusable for comparing against last week's without first re-sorting it by hand.

The version that reports what it can verify, in an order that holds still:

def build_report(run):
    """Build the report as a list of lines. Every number is derived from run."""
    accepted = run["responses"]
    lines = [f"source: {run['source']}", f"run at: {run['run_at']}"]
    lines.append(f"read: {run['read_count']}")
    lines.append(f"accepted: {len(accepted)}")
    lines.append(f"quarantined: {run['quarantined_count']}")
    lines.append(f"reconciled: {run['read_count'] == len(accepted) + run['quarantined_count']}")
    totals = aggregate_by_team(accepted)
    lines.append("teams: " + ", ".join(f"{n}={totals[n]}" for n in sorted(totals)))
    return lines

Every number in it is read from run or computed from something that is, quarantined_count appears explicitly so a reader can tell a quarantined record from a vanished one, and the reconciliation line makes the claim "these numbers add up" checkable instead of implied.

Try it yourself

Two runs are supplied as JSON, in RUN_A_JSON and RUN_B_JSON, each carrying a source, a timestamp, read_count, quarantined_count, and a list of accepted records. Write aggregate_by_region and build_report: aggregate, reconcile from the run's own numbers, and report regions in sorted order on one joined line.

Loading this exercise…

Practical challenge (optional)

Optional, and the transfer task for this lesson: decide what the report should say about a region with no accepted records at all.

aggregate_by_region only ever reports a region that appears in at least one accepted record. Extend the report so it also states which regions from a supplied list of expected regions - say, every region the business operates in - are missing from this run's accepted records entirely, and decide what that should mean: is a region with zero accepted shipments today a fact worth a line in the report, or noise?

What a good answer looks like

A region with genuinely zero shipments today is real information a reader might want - "east: 0" is different from "east was never mentioned" the same way an empty site_closures file was different from a missing one, back in the module on planning a run. The defensible answer prints every expected region, including the ones with a total of zero, rather than only the ones that happen to have an accepted record - because the region that stopped shipping entirely is exactly the one a summary that only reports what it saw would never mention.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. Name the two pieces of run metadata a report opens with, and what a reader loses without them.
  2. Why should accepted in a report be len(accepted_records) rather than a count passed in separately?
  3. Why does a breakdown get joined into one line instead of printed one line per item?
  4. A report prints reconciled: True on every run it has ever produced, including runs a person later found had dropped records. What is the most likely cause?
Answers
  1. The source it read and when it ran. Without them, a report six weeks old is indistinguishable from any other run over any other data - a reader has no way to know which numbers they are looking at.
  2. A count passed in separately can drift from the records list the moment anything between the two touches one and not the other. Deriving it from the list at the moment the report is built means the number can never disagree with the evidence it is supposed to describe.
  3. Printing separately lets the lines land in whatever order the loop happened to produce; nothing then confirms that order rather than merely that every line is present. Joining a sorted list into one line makes the whole order part of a single string that can be checked exactly, and that two runs over related data will report in the same order.
  4. reconciled is very likely a hardcoded True, or is computed from numbers that never independently disagree with each other - the report is not actually comparing read against accepted + quarantined, so it can never produce anything else no matter what really happened during the run.

Sign in to track your progress on this exercise.

Summary and next step

A report is a list of lines built as data before anything prints: what was read, when, and from where; the counts read, accepted, and quarantined; a reconciliation line computed from those three numbers rather than assumed; and an aggregate breakdown sorted before it is joined into a single line, so two reports over related runs can be compared line by line. A report that never checks its own arithmetic is a summary of what the code believed happened, not of what happened. That closes this module: records arrive, get coerced or rejected, get validated or quarantined, and end up in a report whose numbers a reader can trust because the report checked them itself. The next module turns to data a job cannot read from a file at all - a service on the other end of a request, which will not always answer, and will not always answer quickly.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.