LESSON 68 · DEEPER PYTHON30 min

Process JSON Lines One Record at a Time

Distinguish one JSON document from line-delimited records and validate each decoded object.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Process JSON Lines One Record at a Time

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    A log export arrives as one JSON object per line. Each event has a name such as view or click, and the next record starts on the next line. If we paste the whole text into json.loads, the decoder reports Extra data. That sounds like a malformed file, but each line can be perfectly valid JSON. The mismatch is between the file convention and the decoder call: json.loads expects one complete top-level JSON value, while this export contains several independent values. Today we will reproduce the failure, then build a line-oriented reader that preserves physical line numbers.

  2. 00:41

    We will skip blank lines under a stated policy, parse each nonblank record, validate that it is an object with a nonempty string event, and count accepted event names. The website task uses io.StringIO so the browser console can practice this without uploading private logs. The same loop can process a local text file later. Our checks will include malformed syntax and a syntactically valid JSON value that is not a valid event. That distinction is central to reliable data handling: decoding text and accepting an application record are two different gates. The decoder successfully reads the first object.

  3. 01:22

    It then expects only whitespace until the end of this one JSON document. Instead it finds the second opening brace, so it raises JSONDecodeError with the message Extra data. We catch that error here only to keep the demonstration running. The objects are individually valid; the way we grouped them in one call is wrong for the format. One tempting repair is to split the text at every closing brace. That fails as soon as an event contains a nested object or a brace inside a quoted string. Another temptation is to wrap the entire file in square brackets and add commas. That can work as a one-time conversion, but then we are no longer processing the

  4. 02:05

    line-oriented export as received. The direct solution is to treat each line as its own JSON document. This lets a large local file be read progressively instead of making one giant string, although our browser exercise uses a short StringIO sample. It also gives us a natural place to report which physical line contains a mistake. Before writing the final function, we need to see how blanks affect those line numbers. enumerate starts at one so its number matches the line number in an editor. We skip the blank second line, but the count of physical lines still advances.

  5. 02:45

    If line three were malformed, reporting it as event number two would send a human to the wrong place. The final line shows a second layer of validation. An empty list is perfectly valid JSON, so json.loads accepts it. Yet it is not a dictionary representing an event and does not contain an event name. Likewise, a JSON number, string, or null can be syntactically valid while violating the import contract. We therefore need to validate the decoded Python object after parsing. Our task requires an object with a nonempty string under the key event. A missing key, a number as the event value, and an empty event string all fail.

  6. 03:28

    We should not merely write record['event'] and wait for KeyError or TypeError to appear far away from the source. The function can raise ValueError with a useful line number at the point where it detects the bad record. That makes debugging a data export much easier than a generic failure while updating a counter. The function begins with an empty count dictionary. For each physical line, it ignores only blank text and sends every nonblank line to json.loads. A syntax error is translated into ValueError that identifies the line. The original JSONDecodeError is chained with from error, so a developer can still

  7. 04:08

    inspect the detailed cause in a traceback. Next comes the record contract. The value must be a dictionary, its event field must be a string, and that string must not be empty. Only after all three conditions pass do we update the counter. The dictionary preserves the first-seen event order in current Python, so the printed result shows view before click. For a production system, we might choose to collect several row errors and continue processing valid records, or cap how much data is held in memory. This introductory importer stops at the first error because a clear failure is easier to reason about than silently dropping data.

  8. 04:51

    Notice that a blank line and a malformed line are treated differently. A blank line is ignored by policy; a nonblank line that cannot be decoded is reported as an error. That distinction should be documented whenever an import can discard input. The first assertion checks the full counting behavior, including a repeated view event. The second confirms that a stream of blanks produces an empty result rather than an exception or a made-up event. The next check deliberately places invalid JSON after a blank line. We catch ValueError and require that its message mention line three, because source location is part of the task's useful behavior.

  9. 05:35

    Finally, we test records that json.loads would accept but the application must reject: an array, an empty event name, and a numeric event name. This is a stronger test than checking only syntax failure. A parser can be technically correct and still admit unusable data. Try adding an event object with extra keys. This function accepts it because its contract only requires event to be a nonempty string; a stricter schema would need to say otherwise. Then try a record whose event value is whitespace, and decide whether your application should strip or reject it. For this lesson, whitespace is technically a nonempty string, so it passes; changing

  10. 06:18

    that policy would require changing both the function and its tests. On the lesson page, the starter function counts only the first line. Replace that shortcut with the line-by- line reader, run the practical checker, and use the quiz to verify that you can explain why valid JSON and valid event records are not synonymous.

Understand the concept

A normal JSON document is one value, often a list or a dictionary. JSON Lines is a practical convention in which each nonblank line contains one complete JSON value. That makes a log or export easy to append and process record by record. Passing the entire multi-line text to json.loads fails with Extra data, because the decoder expects one document. The format and the reader must agree before any event counting starts.

Iterating a text stream gives one line at a time. In this browser lesson, io.StringIO wraps a sample string in an in-memory stream; the same loop shape works with a local file. enumerate(..., start=1) supplies a human-readable line number. Strip whitespace and skip blank lines, then call json.loads on each remaining line. A blank line is not a JSON value, so deciding to ignore it is an explicit import policy.

Successful JSON decoding alone does not prove a usable event. A line containing 7 or [] is valid JSON but fails this task's record contract. Require an object with an event key whose value is a nonempty string. When syntax or shape fails, raise ValueError that includes the source line number. Chaining the original JSONDecodeError preserves debugging detail without exposing an unhelpful stack trace to a learner using the function.

count_events returns counts in first-seen order. The task checks multiple records, blank lines, a repeated event, an invalid JSON line, and a valid JSON value of the wrong shape. It keeps each row independent: one malformed line cannot quietly merge with its neighbor. This pattern is useful for audit logs and data pipelines, but a production importer may choose to collect errors while continuing rather than fail at the first bad record.

  • JSON Lines contains one JSON value per line
  • A stream can be iterated without parsing one giant document
  • Syntax validity differs from record validity
  • Line-numbered errors make imports debuggable

See it step by step

Read the code, predict the output, then compare it with the result.

01. Show why two documents are not one JSON document

PYTHON
import json
text = '{"event":"view"}\n{"event":"click"}'
try:
    json.loads(text)
except json.JSONDecodeError as error:
    print(error.msg)
EXPECTED OUTPUT
Extra data

The decoder finishes the first object and finds another document where only whitespace could follow.

02. Decode one line at a time

PYTHON
import io, json
text = '{"event":"view"}\n\n{"event":"click"}\n'
for number, line in enumerate(io.StringIO(text), 1):
    if line.strip():
        print(number, json.loads(line)['event'])
EXPECTED OUTPUT
1 view
3 click

The blank second line is skipped while source line numbers remain accurate.

03. Separate valid JSON from valid events

PYTHON
import json
for line in ('7', '{"event":""}', '{"event":"view"}'):
    value = json.loads(line)
    valid = isinstance(value, dict) and isinstance(value.get('event'), str) and bool(value['event'])
    print(valid)
EXPECTED OUTPUT
False
False
True

A decoded JSON value still needs the required object shape and nonempty event string.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Show why several records are not one JSON document

The text contains two independently valid JSON objects, one per line. json.loads is a decoder for one top-level JSON value, so giving it both at once produces Extra data. The error is evidence that the data format is line-delimited, not evidence that either individual object is malformed. Decide the record boundary before writing a parser.

Catching JSONDecodeError here lets the demonstration continue and shows the useful part of its message. In a real import, an error should also identify which input line failed. That is possible only after moving the decoding operation inside a line-by-line loop. Avoid attempting to split at every closing brace; braces can occur inside strings and nested objects.

PYTHON
import json
text = '{"event":"view"}\n{"event":"click"}'
try:
    json.loads(text)
except json.JSONDecodeError as error:
    print(error.msg)
EXPECTED OUTPUT
Extra data
Follow the reasoning
  1. The first object is a complete document.
  2. The second object is extra to json.loads.
  3. A line-oriented reader matches the actual format.
02 / 03

Keep physical line numbers while parsing

StringIO turns the example text into an iterable stream of lines. enumerate with a starting value of one records the same line numbers a person sees in an editor. Strip each line for the blank check and skip empty lines under the chosen import policy. Decode each remaining line separately, leaving the source stream unmodified.

The second line is blank, so the second event appears on physical line three. This matters when a later record is broken: reporting record index two would point a user to the wrong place. The function still needs to validate decoded shapes, because json.loads can legitimately return a number, list, string, or null.

PYTHON
import io, json
text = '{"event":"view"}\n\n{"event":"click"}\n'
for line_number, raw in enumerate(io.StringIO(text), 1):
    if raw.strip():
        print(line_number, json.loads(raw)['event'])
EXPECTED OUTPUT
1 view
3 click
Follow the reasoning
  1. The blank line is ignored.
  2. The source line count still advances.
  3. Each nonblank line is decoded independently.
03 / 03

Count only validated event records

The final function checks two layers: JSON syntax and application shape. It translates invalid JSON into ValueError containing the physical line number, while an object lacking a nonempty string event receives its own ValueError. Chaining the original decoder exception retains technical detail for a developer without requiring callers to understand a raw decoder message.

Counts increase in first-seen order as valid records arrive. A blank line contributes nothing, and a repeated event increments its existing count. The checked task also supplies the valid JSON value [] to make sure you did not equate syntactic validity with a valid event. An importer that needs partial results could later collect errors instead of stopping.

PYTHON
import io, json
def count_events(text):
    counts = {}
    for number, raw in enumerate(io.StringIO(text), 1):
        if not raw.strip():
            continue
        try:
            record = json.loads(raw)
        except json.JSONDecodeError as error:
            raise ValueError(f'line {number}: invalid JSON') from error
        if not isinstance(record, dict) or not isinstance(record.get('event'), str) or not record['event']:
            raise ValueError(f'line {number}: invalid event')
        event = record['event']
        counts[event] = counts.get(event, 0) + 1
    return counts
print(count_events('{"event":"view"}\n\n{"event":"view"}\n'))
EXPECTED OUTPUT
{'view': 2}
Follow the reasoning
  1. Decode and validate each nonblank line.
  2. Include source line numbers in both error paths.
  3. Count only accepted events.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnComplete count_events(text). Read each nonblank line as an independent JSON object, require a nonempty string event field, and return counts by event name. Raise ValueError mentioning the one-based line number for malformed JSON or an invalid record. The prepared output must be {'view': 2, 'click': 1}.
Need a hint?

Loop over enumerate(io.StringIO(text), 1), skip blank lines, and json.loads one stripped line. Validate isinstance(record, dict) and event as a nonempty string; add to counts.

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.Why does json.loads on several JSON Lines commonly raise Extra data?

    02.Why use enumerate(stream, 1)?

    03.Is the JSON value 7 a valid event for this task?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Parsing the whole stream as one document

    COMMON MISTAKE
    records = json.loads(text)
    FIX
    for line in io.StringIO(text):
        record = json.loads(line)

    What happens: Multiple top-level objects produce Extra data.
    Decode one nonblank line at a time under the chosen JSON Lines convention.

    Trusting every decoded value

    COMMON MISTAKE
    counts[record['event']] += 1
    FIX
    if not isinstance(record, dict) or not isinstance(record.get('event'), str) or not record['event']:
        raise ValueError('invalid event')

    What happens: Lists, numbers, and records with missing or empty event values fail unpredictably.
    Validate the application schema after decoding syntax.

    Browse this lesson’s problem fixes

    Sources and further reading