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
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 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.
- 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.
- 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
- 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.
- 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.
- 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
- 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.
- 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.
- 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
- 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
import json
text = '{"event":"view"}\n{"event":"click"}'
try:
json.loads(text)
except json.JSONDecodeError as error:
print(error.msg)Extra dataThe decoder finishes the first object and finds another document where only whitespace could follow.
02. Decode one line at a time
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'])1 view
3 clickThe blank second line is skipped while source line numbers remain accurate.
03. Separate valid JSON from valid events
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)False
False
TrueA 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.
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.
import json
text = '{"event":"view"}\n{"event":"click"}'
try:
json.loads(text)
except json.JSONDecodeError as error:
print(error.msg)Extra data- The first object is a complete document.
- The second object is extra to json.loads.
- A line-oriented reader matches the actual format.
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.
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'])1 view
3 click- The blank line is ignored.
- The source line count still advances.
- Each nonblank line is decoded independently.
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.
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')){'view': 2}- Decode and validate each nonblank line.
- Include source line numbers in both error paths.
- 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 runNeed input()? Add one value per line
Your output appears here.
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.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Parsing the whole stream as one document
records = json.loads(text)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
counts[record['event']] += 1if 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.