Skip to main content
Learning Center
Workflow Automation

Process Everyday Data

Validating a Batch Without Stopping It

Checking each already-typed record against the rules that a parser could never enforce, letting a bad one fall into a quarantine list with its identifier, field, and reason while every other record keeps moving, and naming the run's outcome instead of collapsing it to pass or fail.

Lesson 8 of 18 in the recommended order · About 30 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 check a batch of already-typed records against rules that only make sense once you know the type - a quantity that must be positive, a region that must be one of a known set - and continue the batch past a record that fails one, sending it to a quarantine list that names the record, the field, and the reason, while reporting the run's outcome honestly instead of forcing it into pass or fail.

Why it matters

The previous lesson's coercion catches a record that cannot become the type it was promised. It cannot catch a quantity of -40, or a region of "atlantis" - both are perfectly good integers and strings, and neither one is a fact a parser could have objected to. Those rules live one level up, and they are where most real batches actually fail: not because the export is broken, but because one order really did arrive with a negative count, or one row really was typed into the wrong region field.

The instinct that produces the worst outcome here is treating the whole batch as one unit: find a bad record, stop, report the failure. A supplier feed with nine hundred good rows and one bad one does not deserve to have its other eight hundred and ninety-nine held hostage by the one that is wrong, and a person reading the result the next morning does not want "the job failed" when what actually happened is "eight hundred and ninety-nine rows are fine and one needs a look."

The other bad outcome looks safer and is not: silently dropping the bad record and reporting only the good ones. That is a number quietly smaller than the batch it came from, with nothing in the output to say why, which is the same failure the previous lesson spent its WhyItMatters section on, one layer further into the pipeline.

The alternative this lesson builds: every record gets a decision. It is accepted, or it is quarantined with enough information that somebody could fix the source and re-run, and the counts always add back up to the number the job started with.

Concept

Validation is a rule about a value, not about its type. "Is quantity an int" is the previous lesson's question. "Is quantity greater than zero" is this lesson's - it can only be asked once the coercion has already happened, and it is a business rule, not a parsing one. A batch usually needs several: a quantity that must be positive, a region that must be one of a known set, a date that must not be in the future. Each rule names the field it checks and the condition that field must satisfy.

A bad record does not stop the batch. The loop visits every record. For each one, it checks rules in order until the first failure or until all rules pass; it appends the record to one list or the other and moves to the next one. This is the single most important property in this lesson, and it is also the easiest one to lose by accident - a return where a continue was meant, or a validator that raises instead of reporting, silently turns "quarantine the bad ones" back into "stop at the first one."

Quarantine, with enough information to act on. A quarantined record is not just excluded - it carries its identifier, the specific field that failed, and the reason, the same three pieces of information the previous lesson's rejections carried for a shape problem. "Record 4218 failed validation" tells a reader nothing they can act on before finding the record and re-deriving what went wrong; "record 4218: quantity -40, must be greater than zero" tells them exactly what to check.

When more than one rule fails on the same record, the order you check them in is the answer you give. A record with both a bad quantity and a bad region fails two rules at once, and a validator that checks quantity first will only ever report quantity for that record - which is fine, as long as the order is a decision that is written down, not an accident of which if happened to come first in a draft. Reporting every failed rule instead of just the first is a reasonable design too; it is a different decision, and it has to be made on purpose, the same way the earlier lesson on refusing a run had to decide between stopping at the first problem and collecting all of them.

Partial success is an outcome with a name, not a percentage. A batch where everything passed is complete. A nonempty batch where nothing passed is refused - worth distinguishing because zero accepted records may signal an upstream problem. With no records at all, the shown batch_status returns complete because nothing was quarantined; a real job can give an empty input its own status if that distinction matters. A batch that is neither is partial, and partial is not a lesser version of success; it is the normal, expected shape of a batch that touches the real world, and the run record this feeds should say so in those words rather than reducing it to a single true-or-false.

The counts must reconcile. The loop's append-once structure routes each supplied record into accepted or quarantined. read == accepted + quarantined checks for an unbalanced count loss within this batch, such as an early return; the arithmetic alone cannot prove that a duplicate and an omission did not cancel. It also says nothing about rows lost before this function received its input.

Read the code

A small batch of already-typed expense claims, checked against two rules:

CLAIMS = [
    {"id": "c1", "amount": 42, "category": "travel"},
    {"id": "c2", "amount": -15, "category": "meals"},
    {"id": "c3", "amount": 30, "category": "decor"},
    {"id": "c4", "amount": 8, "category": "meals"},
]

ALLOWED_CATEGORIES = {"travel", "meals", "supplies"}


def validate_claim(claim):
    """Return None if claim is valid, or {"field": ..., "reason": ...}."""
    if claim["amount"] <= 0:
        return {"field": "amount", "reason": "must be greater than zero"}
    if claim["category"] not in ALLOWED_CATEGORIES:
        return {"field": "category", "reason": "not an approved category"}
    return None


def validate_claims(claims):
    accepted, quarantined = [], []
    for claim in claims:
        problem = validate_claim(claim)
        if problem is None:
            accepted.append(claim)
        else:
            quarantined.append({"id": claim["id"], **problem})
    return accepted, quarantined


accepted, quarantined = validate_claims(CLAIMS)
print("read:", len(CLAIMS))
print("accepted:", len(accepted))
print("quarantined:", len(quarantined))
print("reconciled:", len(CLAIMS) == len(accepted) + len(quarantined))
for entry in quarantined:
    print("quarantine:", entry["id"], entry["field"], "-", entry["reason"])

validate_claims appends to one list or the other on every pass through the loop and never returns early, which is what lets it reach c4 even though c2 and c3 both failed before it.

{"id": claim["id"], **problem} builds the quarantine entry by combining the identifier with whatever validate_claim returned, so the entry always carries all three pieces: which record, which field, and why - without validate_claim needing to know the record's id at all.

len(CLAIMS) == len(accepted) + len(quarantined) is checked directly against the two lists this run actually produced, not assumed. It catches an unbalanced loss such as an early return. The loop's one-append-per-claim structure, rather than count equality alone, establishes that each claim has one outcome.

Predict the output

Predict every line.

Check your prediction
read: 4
accepted: 2
quarantined: 2
reconciled: True
quarantine: c2 amount - must be greater than zero
quarantine: c3 category - not an approved category

c1 and c4 pass both rules. c2's amount of -15 fails the first rule and is quarantined before its category is even checked. c3's amount of 30 passes, and its category of "decor" is not in ALLOWED_CATEGORIES, so it is quarantined on the second rule.

Four claims, four recorded decisions

Worked batch: 4 read = 2 accepted + 2 quarantined

The loop appends once for each supplied claim. Equal counts confirm no unbalanced loss here; the loop structure proves each claim gets one outcome.

c1 · accepted

Input
amount 42; category travel
Decision
Passes both ordered rules

The accepted list retains the claim.

c2 · quarantined

Input
amount −15; category meals
First failed rule
amount must be greater than zero
Retained entry
id c2; field amount; reason

Its category is not checked after the first failure.

c3 · quarantined

Input
amount 30; category decor
First failed rule
category is not approved
Retained entry
id c3; field category; reason

The quarantine entry stores id, field and reason, not a full claim copy.

c4 · accepted after two failures

Input
amount 8; category meals
Decision
Passes both ordered rules

Reaching c4 shows that c2 and c3 did not stop the batch.

The split is retained evidence, not deletion. A separate status rule can name complete, partial or refused; this four-claim worked batch does not exercise every status.

Modify the code

Change validate_claims so a failure stops the batch instead of continuing it: replace the else branch's quarantined.append(...) with return accepted, [{"id": claim["id"], **problem}], returning immediately on the first bad claim.

What changes, and why
read: 4
accepted: 1
quarantined: 1
reconciled: False
quarantine: c2 amount - must be greater than zero

c1 is accepted, c2 is quarantined, and the function returns right there - c3 and c4 are never looked at again. reconciled is now False, because 1 + 1 is 2 and the batch had 4 records; two of them simply vanished from the run with no error and no line in the output naming them.

This is worth sitting with, because nothing here raised. validate_claims returned two lists, both well-formed, and a caller with no reason to suspect a problem would print the summary and move on. The reconciliation check is the only thing in this program that notices two claims went missing, which is exactly why it belongs in every version of this function rather than being an occasional sanity check run by hand.

Debug the bug

An assistant was asked to "validate this batch of claims and skip the bad ones". It produced this.

def validate_claims(claims):
    accepted = []
    for claim in claims:
        if claim["amount"] > 0 and claim["category"] in ALLOWED_CATEGORIES:
            accepted.append(claim)
    return accepted
What's actually wrong

It does skip the bad ones, in the narrowest possible reading of the request, and the result is unusable for anything except counting how many claims passed.

  1. There is no quarantine. A claim that fails either rule is not in accepted, and it is not anywhere else either. Nobody can tell how many claims were skipped, which ones, or why, from looking at this function's return value.
  2. The two rules are combined with and, so a failure reports nothing about which rule failed. A claim with a negative amount and a disallowed category looks, from outside this function, identical to a claim that merely has a negative amount - both are simply absent from accepted.
  3. There is no reconciliation. len(accepted) compared against len(claims) would at least reveal that something was excluded; this function does not even return that much, so a caller has to remember to compute len(claims) - len(accepted) and would have no idea whether the discrepancy meant "twelve invalid claims" or "twelve claims silently dropped by a bug in this function" - which is a distinction the function itself is in the best position to make and the only one that cannot make it.
  4. "Skip" and "quarantine" are not the same instruction, and the difference matters here specifically. Skipping is what you do to a duplicate you have already accounted for. A claim that failed validation is new information about a problem somebody has to see, and dropping it silently is a worse outcome than the crash the assistant was presumably asked to avoid.

The version that reports what it decided:

def validate_claims(claims):
    """Split claims into (accepted, quarantined). Nothing is silently dropped."""
    accepted, quarantined = [], []
    for claim in claims:
        problem = validate_claim(claim)
        if problem is None:
            accepted.append(claim)
        else:
            quarantined.append({"id": claim["id"], **problem})
    return accepted, quarantined

Two lists come back instead of one, every claim is in exactly one of them, and each quarantined entry names the field and reason validate_claim found - so a caller can print len(claims) == len(accepted) + len(quarantined) and have it mean something.

Try it yourself

Two batches of already-typed shipment records are supplied as JSON, in RECORDS_A_JSON and RECORDS_B_JSON. Write validate_record, validate_batch, and batch_status: check quantity before region, quarantine without stopping, and name each batch's outcome.

Loading this exercise…

Practical challenge (optional)

Optional, and the transfer task for this lesson: decide what happens when a rule needs more than one record to check.

Every rule in this lesson looks at one record at a time. A real batch often needs a rule that cannot: "no two claims share the same receipt number" needs to see the whole batch before it can quarantine either one. Write duplicate_ids, which takes a batch and returns the set of ids that appear more than once, and decide how validate_batch should use it - does a duplicate quarantine both records, or only the second one it sees?

What a good answer looks like

Quarantining only the second occurrence assumes the first one is correct, which is exactly the kind of silent, order-dependent decision this course has spent two modules arguing against - it is the same defect as the renaming lesson's collision, wearing record validation's clothes. The defensible answer quarantines every record sharing a duplicated id, with a reason naming how many copies were found, and leaves the decision about which one is genuine to a person, the same way a filename collision was left to one rather than resolved by whichever file the filesystem happened to list first.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. What is the difference between a shape check from the previous lesson and a validation rule from this one?
  2. Why must a validator continue past a record that fails, rather than stopping there?
  3. Name the three outcomes a batch can report, and what distinguishes each from the other two.
  4. read == accepted + quarantined is False after a run that raised no exception. What does that tell you?
Answers
  1. A shape check asks whether a value can become its declared type at all - "12" can become an int, "soon" cannot. A validation rule asks something about the value once it already has that type - whether a quantity that is genuinely an int is also a positive one.
  2. A batch is not one thing succeeding or failing; it is many records, most of which are usually fine. Stopping at the first failure punishes every record after it for a problem that belongs only to the one that failed.
  3. complete, nothing was quarantined (including an empty batch in this example); partial, some records were accepted and some were quarantined; refused, a nonempty batch had nothing accepted. An all-quarantined batch may signal an upstream problem, so it deserves its own outcome.
  4. The result has an unbalanced count within the batch this function received. An early return is one possible cause: it leaves later records unprocessed while returning two well-formed lists. Check the loop and inputs; the count alone does not identify which records are missing.

Sign in to track your progress on this exercise.

Summary and next step

Validation checks a rule about a value the parser could never have enforced, and it runs record by record without ever stopping the batch. A record that fails goes to quarantine carrying its identifier, the specific field, and the reason; the check order that decides which field gets reported on a record that fails two rules at once is a decision, written down, not an accident of which if came first. Name the batch's outcome as complete, partial, or refused, and reconcile the count read against the counts accepted and quarantined every time - a mismatch with no exception is the failure this check exists to catch. The next lesson takes the accepted records this one produces and turns them into the artefact a person actually reads.

learning.goultergroup.com

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