Skip to main content
Learning Center
Workflow Automation

Process Everyday Data

Reading Records That Disagree With What You Were Told

Parsing a batch of arriving records against the shape a job was promised, coercing each field to its declared type or naming the one that will not coerce, so a bad record produces a reason instead of a half-built dictionary.

Lesson 7 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 a batch of records arriving at the boundary of an unattended job, check each one against the shape the job was promised, coerce a field that arrived as a plausible other type, and produce either a typed record or a specific reason - never a dictionary that is partly one and partly a guess.

Why it matters

Every automation in this course eventually reads a batch of records from somewhere it does not control: an export a colleague generates, a report a supplier emails, a feed a service publishes. csv.DictReader and json.loads will hand back something for almost anything you point them at, and "something" is exactly the problem. Neither one knows what your job was promised, so neither one can tell you when what arrived does not match it.

The failures are not exotic. A field that was a plain integer for eight months arrives as a quoted string because somebody re-exported it from a spreadsheet. A column gets renamed upstream and every record in the batch is suddenly missing a field your job depends on. A new field gets added, and code that assumes a fixed set of keys either ignores it or trips over it, depending on luck. None of this is malformed CSV or invalid JSON - the parser is satisfied every time - so your code is the first thing in the chain with any chance of noticing.

The instinct to reach for try/except and move on is close to right and wrong in one specific way: catching the exception is correct, and catching it in order to skip the record without saying why is not. A record that fails to parse and vanishes silently is indistinguishable, from the outside, from a record that was never sent. Six months later, "we processed everything" and "we quietly dropped four rows a month" look like the same report.

Concept

A schema is the shape the job was promised, written down as a small mapping from field name to declared type: {"id": "str", "quantity": "int", "site": "str"}. It is not a validation library and does not need to be one - a dictionary and a few isinstance checks say everything a boundary read needs to say.

Three questions, asked in order, before any value is trusted:

  1. Does this record carry a field the schema does not name? An extra field is often the first sign of a header that changed - somebody added a column upstream, and code that ignores unknown keys will silently carry it nowhere, which is a quieter failure than an error.
  2. Does this record lack a field the schema requires? A missing field is the mirror case, and it is usually the same event: a column got renamed rather than added, so the new name shows up as an extra field and the old name shows up as a missing one, on every record in the batch.
  3. Does every present field's value coerce to its declared type? A JSON field is sometimes a string and sometimes a number depending on which system produced it this month - a spreadsheet export quotes everything, an API often does not. Coercion means accepting the value the type can honestly become: the string "12" becomes the integer 12. It does not mean accepting anything a program can be made not to crash on.

Coerce, or report - never guess. A field that will not coerce is not evidence about what the value should have been. int("soon") cannot become a quantity, and the honest response is a reason naming the field, the record, and what was found - not a 0, not a None standing in as though it were data, and not a silent skip. A guessed value is worse than a rejected one, because a rejected record shows up in a count and a guessed one does not.

A repeated reason is a different problem than a single one. One record rejected for "missing field quantity" is a bad row. Every record in the batch rejected for the same reason is not forty bad rows - it is one broken export, and the fix is a conversation with whoever produces it, not a loop that tries harder. The per-record reason is what makes that pattern visible; a batch that reports one bare "invalid input" count could not tell you which case you were looking at.

Parse into a typed record or a rejection reason - nothing in between. The function that does this returns one or the other, never a dictionary with some fields converted and others left as raw strings, and never raises past its own boundary. What happens to the rejected ones is the next lesson's question.

Read the code

A schema, a small batch of raw ticket exports, and the coercion that checks them:

import json

RAW = json.loads(
    '[{"ticket": "T1", "minutes": "45"}, {"ticket": "T2", "minutes": 12}, '
    '{"ticket": "T3", "minutes": "soon"}]'
)

SCHEMA = {"ticket": "str", "minutes": "int"}


def coerce_record(raw, schema):
    """Coerce one raw record to schema, or return (None, reason)."""
    extra = set(raw) - set(schema)
    if extra:
        name = sorted(extra)[0]
        return None, f'unexpected field "{name}"'
    missing = set(schema) - set(raw)
    if missing:
        name = sorted(missing)[0]
        return None, f'missing field "{name}"'
    record = {}
    for name, kind in schema.items():
        value = raw[name]
        if kind == "int":
            if isinstance(value, int):
                record[name] = value
            elif isinstance(value, str):
                try:
                    record[name] = int(value.strip())
                except ValueError:
                    return None, f'field "{name}" is not a valid int: {value!r}'
            else:
                return None, f'field "{name}" is not a valid int: {value!r}'
        else:
            record[name] = value
    return record, None


def parse_records(raw_records, schema):
    records, rejections = [], []
    for raw in raw_records:
        record, reason = coerce_record(raw, schema)
        if reason is None:
            records.append(record)
        else:
            rejections.append((raw["ticket"], reason))
    return records, rejections


records, rejections = parse_records(RAW, SCHEMA)
print("read:", len(RAW))
print("parsed:", len(records))
for record in records:
    print(f"  {record['ticket']}: {record['minutes']} minutes")
for ticket, reason in rejections:
    print(f"  {ticket} rejected: {reason}")

set(raw) - set(schema) and set(schema) - set(raw) are the two shape checks, and they run before any field is inspected for its type - a record with the wrong fields does not get far enough to have the wrong values.

isinstance(value, int) is checked before isinstance(value, str), so T2's minutes of 12 is accepted as-is and never gets a pointless round trip through int(str(12)). T3's minutes of "soon" reaches the try, fails to convert, and the except turns that failure into a returned reason rather than an exception the caller has to catch.

parse_records never raises. Every raw record produces either an appended typed record or an appended (ticket, reason) pair, and the loop always reaches the next one.

Predict the output

Predict every line.

Check your prediction
read: 3
parsed: 2
  T1: 45 minutes
  T2: 12 minutes
  T3 rejected: field "minutes" is not a valid int: 'soon'

T1's minutes arrives as the string "45" and coerces to the integer 45. T2's arrives already as the integer 12 and passes through unchanged. T3's arrives as "soon", which int() cannot parse, so coerce_record returns a reason instead of a record, and parse_records records it as a rejection rather than stopping.

Modify the code

Inside the except ValueError: branch, replace return None, f'field "{name}" is not a valid int: {value!r}' with record[name] = 0, so a value that will not convert becomes a zero instead of a rejection.

What changes, and why
read: 3
parsed: 3
  T1: 45 minutes
  T2: 12 minutes
  T3: 0 minutes

T3 is no longer rejected. It is reported as a ticket that took zero minutes to resolve, which is not a fact about T3 - it is the absence of one, wearing the shape of a fast resolution. Nothing downstream can tell the difference between a ticket that was genuinely resolved instantly and a ticket whose duration nobody could read, because the record no longer carries any sign that a problem happened here.

This is the general shape of a coercion defect: it does not crash, it does not print a warning anyone will see, and the batch summary looks better than it should - parsed: 3 instead of parsed: 2, with a rejection quietly converted into a plausible-looking data point.

Debug the bug

An assistant was asked to "load these ticket exports and give me typed records". It produced this.

def load_tickets(raw_records):
    tickets = []
    for raw in raw_records:
        tickets.append({"ticket": raw["ticket"], "minutes": int(raw["minutes"])})
    return tickets
What's actually wrong

It works on the two clean rows in whatever sample the assistant tested against, and it is wrong in four ways that only show up on the rows that were not in that sample.

  1. raw["ticket"] raises KeyError on a missing field, and there is no check for one. A batch missing the field on even one record stops the function entirely, on a record other than the one somebody will look at first.
  2. int(raw["minutes"]) raises ValueError on a value that will not convert, uncaught, which stops the whole batch rather than rejecting the one record. The nine hundred records after the bad one are never read, and the function reports nothing about any of them.
  3. There is no check for an unexpected field, so a renamed or newly added column is silently invisible - load_tickets neither uses it nor reports that it exists, which is exactly the case where a header change goes unnoticed the longest.
  4. A single bad record takes the whole batch down. The function returns a list or raises; it has no way to return "897 tickets and here are the three that did not parse", which is the only shape of answer that is actually useful at eight in the morning.

The version that reports instead of crashing:

def parse_tickets(raw_records, schema):
    """Coerce every record against schema. A bad record never stops the batch."""
    records, rejections = [], []
    for raw in raw_records:
        record, reason = coerce_record(raw, schema)
        if reason is None:
            records.append(record)
        else:
            rejections.append({"ticket": raw.get("ticket", "?"), "reason": reason})
    return records, rejections

Every record gets a decision. A record that fails coercion is named in rejections with the reason it failed, and the loop reaches the last record in the batch no matter how many before it were bad. raw.get("ticket", "?") is what keeps a record that is missing even its identifying field from raising while the code tries to name it in the rejection.

Try it yourself

Five raw records are supplied as JSON in RAW_JSON, checked against the schema id (str), quantity (int), site (str). Write coerce_record and parse_records: coerce what can honestly be coerced, and return a specific reason for what cannot.

Loading this exercise…

Practical challenge (optional)

Optional, and the transfer task for this lesson: decide what a schema does not yet say.

JSON has two number types where this lesson's schema has one: 45 parses to a Python int, 45.0 parses to a Python float, and both could plausibly mean "forty-five" in a field declared "int". Extend coerce_record's int branch to decide, in a comment, what should happen to a float value - and then decide separately what should happen to 45.5, which cannot become an integer without losing information nobody asked to lose.

What a good answer looks like

A defensible rule: a float that has no fractional part (45.0) coerces to the equivalent int, and any float that does have one (45.5) is rejected with a reason, because rounding or truncating it would manufacture a number nobody supplied. The rule that is easy to write and wrong is int(value) applied unconditionally - it silently truncates 45.9 to 45, which is exactly the guessed-value failure this lesson spends its ModifyTheCode section on, arrived at from a different direction.

Worth noticing while you are in there: isinstance(True, int) is True in Python, because bool is a subclass of int. A schema check that accepts "is this an int" without also excluding bool will happily coerce a JSON true into a quantity of 1, which is a real trap in exactly the kind of one-line isinstance check this lesson recommends.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. Why does an extra field usually get checked before a missing one, and what does it mean if a whole batch reports the same pair of shape problems on every record?
  2. Why must a type-coercion failure return a reason rather than letting int()'s exception propagate?
  3. quantity is 7 in one record and "7" in another. What should coerce_record do with each, and why should both produce the same typed value?
  4. A rewritten function defaults a field that will not coerce to 0 instead of rejecting the record. Why is that worse than rejecting it?
Answers
  1. Neither check has to come first for correctness on a single record, but seeing the same shape problem on every record in a batch - not just one - is the sign of a renamed or restructured export, not of many independently bad rows. The per-record reason is what makes that pattern visible at all.
  2. An uncaught exception stops the whole batch at the first bad record, so nothing after it is even read. Catching the exception and returning a reason lets every other record still get a decision.
  3. Both should coerce to the integer 7. isinstance(value, int) accepts the one that already is one, and the str branch's int(value.strip()) converts the other; a job that reads the same field from two different upstream systems this month should not care which one sent it.
  4. A rejected record shows up in a count a person can see and act on. A defaulted one looks exactly like real data - a 0 that means "nobody could tell" is indistinguishable, downstream, from a 0 that means "this genuinely took no time," and every calculation built on it inherits the difference silently.

Sign in to track your progress on this exercise.

Summary and next step

A schema is the shape a job was promised: field names and declared types, checked before any value is trusted. Reject a record with an unexpected field or a missing one before coercing anything, coerce a field to its declared type only when the value can honestly become it, and return a specific reason - never a guessed value - for the one that cannot. A repeated reason across a batch is one broken export wearing many rows. The next lesson takes the records this one accepts, and the ones it rejects, and answers the harder question: what does a job do with a record that parses cleanly but breaks a rule the parser could never have checked?

learning.goultergroup.com

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