Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
JSON Parser (Recursive Descent)
Write the parser behind every API and config file: turn a JSON string into native objects, arrays, numbers, strings, booleans and null — by hand, without the built-in JSON library. Recursive descent mirrors the grammar exactly. Solve it in Python or TypeScript, with hidden tests.
The problem
Implement parse_json(text): parse a JSON string into the language’s native values — objects (dicts/maps), arrays (lists), integers, strings, true/false, and null — without calling the built-in JSON parser. Support nesting and arbitrary whitespace between tokens. (For this exercise, numbers are integers and strings contain no escape sequences.)
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement parse_json(text) with a position cursor and one function per rule. value() dispatches on the next non-space character; object()/array() loop over comma-separated items; string()/number() read literals. Return the fully built native value.
Examples and constraints 5 examples
text = '{"a":[1,2,true],"b":null}'{"a": [1, 2, True], "b": None}text = '[1, -2, 3]'[1, -2, 3]text = '{"x": {"y": 5}}'{"x": {"y": 5}}text = '[ ]'[]text = '"hello"'"hello"- Do not use
json.loads/JSON.parse— build the parser yourself. - Support objects, arrays, integers (with optional leading minus), unescaped strings,
true/false/null, and whitespace. - Recursive descent: one function per grammar rule (value, object, array, string, number), sharing a position cursor.
Hints 4 ideas
- Keep an index
posand askip()that advances past spaces/tabs/newlines. Call it before reading each token. value(): peek the next char — "{" → object, "[" → array, "\"" → string, "t"/"f"/"n" → keyword, else a number.- For an object, consume "{", then repeatedly read a string key, a ":", and a value, separated by ",", until "}". Arrays are the same without keys.
- A number runs from an optional "-" through the digits; stop at the first non-digit (a comma, bracket, or brace).
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious approach works on substrings. To parse an array, strip the outer brackets, split the inside on the commas at depth 0 (counting brackets and braces, and skipping commas inside quotes), and parse each piece recursively; an object splits the same way, then splits each piece at its first colon. It works, because every piece is itself a complete JSON value. But every level rescans and copies its whole substring, so a character nested d levels deep is scanned d times: O(n · d) time and space, which is O(n²) for input like [[[[…]]]]. Forget the quote rule and ["a,b"] splits into two broken pieces.
The approach
One cursor, one pass. JSON is designed so that the first non-whitespace character decides what comes next: { is an object, [ an array, " a string, t, f or n a keyword, anything else a number. So there is no backtracking and no need for a separate tokenizer; the reference parses straight from characters. The invariant every function keeps: it starts with pos on the first character of its construct and returns with pos just past it, so no caller ever rescans.
In the reference, value() calls skip() for whitespace and dispatches. string() steps over the opening quote, scans to the next " and slices, which is safe only because this exercise has no escapes. number() takes an optional - and the digits after it. obj() and arr() consume the opening bracket, return early if it closes at once, then loop: read an item (for an object, a string() key, the : and a value()), then continue on , or step over the closing bracket and stop. Nesting needs nothing special, because an item is a value().
Walk through an example
Trace the first example, parse_json('{"a":[1,2,true],"b":null}'), a 25-character string, watching pos only ever move forward.
| step | function | consumes | pos after | built so far |
|---|---|---|---|---|
| 1 | value() → obj() | { | 1 | d = {} |
| 2 | string(), then the colon | "a": | 5 | key "a" |
| 3 | arr(), two number() calls | [1,2, | 10 | a = [1, 2] |
| 4 | value() keyword check | true | 14 | a = [1, 2, True] |
| 5 | arr() sees no comma | ] | 15 | d = {"a": [1, 2, True]} |
| 6 | obj() comma, string(), colon | ,"b": | 20 | key "b" |
| 7 | value() keyword check | null | 24 | d = {"a": [1, 2, True], "b": None} |
| 8 | obj() sees no comma | } | 25 | returns d; pos is at the end |
Complexity
Optimal: O(n) time, because pos only moves forward, each character is examined a constant number of times (a keyword check looks at most five characters ahead), and each string or number is sliced once; O(n) space for the result plus an O(d) call stack for nesting depth d. Brute force (split and recurse on substrings): O(n · d) time and space, O(n²) when deeply nested.
Edge cases to test
- Empty containers with whitespace:
[ ]→[]and{ }→{}. The early-return check has to come afterskip(). - Structural characters inside a string:
{"a":"}]"}→{"a": "}]"}and["a,b"]→["a,b"].string()scans to the next quote, so braces and commas inside it are just characters. - A top-level scalar: the text
"hello", quotes included, → the stringhello, andfalse→False. Valid JSON does not have to be an object or an array. - Malformed input the reference accepts:
[1 2]→[1], because whatever is not a comma is consumed as the closing bracket;[1]x→[1]andtrueish→True, because nothing checkspos == len(text);01→1, although JSON forbids leading zeros. - Duplicate keys:
{"a":1,"a":2}→{"a": 2}. The last value wins, as it does in Python’sjson.loadsand JavaScript’sJSON.parse; the JSON standard only says that names should be unique. - Deep nesting: each level costs two Python frames (
valueandarr), so about 500 nested brackets exceed the default recursion limit and raiseRecursionError.
Common mistakes
- Not recursing for nested objects/arrays — the parser must call itself for values inside values.
- Scanning a string to the next
"is correct here only because this exercise rules out escape sequences. A full parser must treat\"as part of the string and decode\n,\\and\u00e9-style escapes. - Silently accepting malformed input instead of raising a clear parse error. The reference does this for
[1 2]; it fails on[1,]only by accident, whenint("")raises. - Calling
skip()in too few places: whitespace may appear before and after every token, including just inside{ }and on both sides of a colon.
Follow-ups an interviewer asks next
- How would you support escapes and real numbers?
- In
string(), on a backslash read the next character and map it:\",\\,\/,\b,\f,\n,\r,\t, or\uplus four hex digits, combining two\uescapes when they form a surrogate pair. Innumber(), accept an optional fraction and exponent, return a float when either is present, and reject leading zeros. - How would you report errors usefully?
- Replace every silent
pos += 1with a check of the expected character, such asexpect(":"), and raise ifposhas not reached the end after the top-levelvalue(). Turnposinto a line and column for the message: the line is the number of newlines beforeposplus one, and the column is the distance from the last one. - What if the JSON is a 10 GB file?
- Do not build the tree. A streaming parser reads the input in chunks and emits events, such as start of object, key, value and end of array, as soon as each is complete, so memory is bounded by the nesting depth rather than the file size. Python’s
ijsonlibrary works this way; the caller keeps only the parts it needs. - How do you avoid stack overflow on hostile input?
- Either enforce a maximum depth and raise past it, or make the parser iterative: keep an explicit stack of open containers and push or pop on brackets instead of recursing. The second never hits a recursion limit; the first is simpler and also caps the memory an attacker can make one request consume.
Where this shows up
Writing a JSON parser is the classic introduction to recursive-descent parsing, and the same dispatch-then-recurse structure underlies real programming-language parsers, database query engines, and config loaders. It demystifies exactly how any structured text becomes an in-memory data structure.
Reference solution
Reference solution — Python
def parse_json(text):
pos = 0
def skip():
nonlocal pos
while pos < len(text) and text[pos] in " \t\n\r":
pos += 1
def value():
nonlocal pos
skip()
c = text[pos]
if c == "{": return obj()
if c == "[": return arr()
if c == '"': return string()
if text.startswith("true", pos): pos += 4; return True
if text.startswith("false", pos): pos += 5; return False
if text.startswith("null", pos): pos += 4; return None
return number()
def string():
nonlocal pos
pos += 1 # opening quote
start = pos
while text[pos] != '"':
pos += 1
res = text[start:pos]
pos += 1 # closing quote
return res
def number():
nonlocal pos
start = pos
if text[pos] == "-":
pos += 1
while pos < len(text) and text[pos].isdigit():
pos += 1
return int(text[start:pos])
def obj():
nonlocal pos
pos += 1 # "{"
d = {}
skip()
if text[pos] == "}":
pos += 1; return d
while True:
skip()
key = string()
skip()
pos += 1 # ":"
d[key] = value()
skip()
if text[pos] == ",":
pos += 1; continue
pos += 1 # "}"
break
return d
def arr():
nonlocal pos
pos += 1 # "["
a = []
skip()
if text[pos] == "]":
pos += 1; return a
while True:
a.append(value())
skip()
if text[pos] == ",":
pos += 1; continue
pos += 1 # "]"
break
return a
return value()
print(parse_json('{"a":[1,2,true],"b":null}')) # -> {'a': [1, 2, True], 'b': None}
print(parse_json('[1, -2, 3]')) # -> [1, -2, 3]Reference solution — TypeScript
function parseJson(text: string): any {
let pos = 0;
const skip = () => { while (pos < text.length && " \t\n\r".includes(text[pos])) pos++; };
function value(): any {
skip();
const c = text[pos];
if (c === "{") return obj();
if (c === "[") return arr();
if (c === '"') return str();
if (text.startsWith("true", pos)) { pos += 4; return true; }
if (text.startsWith("false", pos)) { pos += 5; return false; }
if (text.startsWith("null", pos)) { pos += 4; return null; }
return number();
}
function str(): string {
pos++; // opening quote
const start = pos;
while (text[pos] !== '"') pos++;
const res = text.slice(start, pos);
pos++; // closing quote
return res;
}
function number(): number {
const start = pos;
if (text[pos] === "-") pos++;
while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++;
return parseInt(text.slice(start, pos), 10);
}
function obj(): any {
pos++; // "{"
const d: any = {};
skip();
if (text[pos] === "}") { pos++; return d; }
while (true) {
skip();
const key = str();
skip();
pos++; // ":"
d[key] = value();
skip();
if (text[pos] === ",") { pos++; continue; }
pos++; // "}"
break;
}
return d;
}
function arr(): any[] {
pos++; // "["
const a: any[] = [];
skip();
if (text[pos] === "]") { pos++; return a; }
while (true) {
a.push(value());
skip();
if (text[pos] === ",") { pos++; continue; }
pos++; // "]"
break;
}
return a;
}
return value();
}
console.log(JSON.stringify(parseJson('{"a":[1,2,true],"b":null}'))); // -> {"a":[1,2,true],"b":null}
console.log(JSON.stringify(parseJson('[1, -2, 3]'))); // -> [1,-2,3]Questions about JSON Parser (Recursive Descent)
How do you write a JSON parser from scratch?
Keep one position cursor into the string and write one function per kind of value. A value function skips whitespace and looks at the next character: an opening brace means an object, an opening bracket an array, a double quote a string, the letters t, f or n a keyword, and anything else a number. The object and array functions loop over comma-separated items, calling the value function for each one, which is where nesting comes from. Each function leaves the cursor just past what it read, so the whole parse is a single left-to-right pass over the text.
Why does recursive descent suit JSON so well?
JSON was designed so that one character of lookahead is always enough: after skipping whitespace, the next character tells you exactly which kind of value starts there, and no choice ever has to be undone. Grammars with that property can be parsed by plain recursive descent, one function per grammar rule, with no backtracking and no parser generator. The recursion mirrors the data: an array of objects becomes a call to the array function that calls the object function for each item, so the shape of the call stack matches the shape of the document being built.
What is the time complexity of parsing JSON?
Linear, O(n) in the length of the text, for a single-cursor recursive-descent parser. The cursor only moves forward, every character is looked at a constant number of times, and each string or number is copied out once. Space is O(n) for the resulting objects plus call-stack space proportional to the deepest nesting. Approaches that cut the text into substrings and parse each piece separately are slower, because each level of nesting rescans and copies its contents, which is O(n squared) on deeply nested input.
Why can you not parse JSON by splitting on commas?
Because commas mean different things at different depths and inside strings. In an array whose first element is another array, the comma inside the inner array is not a separator for the outer one, and in a string such as a,b the comma is just a character. Splitting correctly means tracking bracket depth and whether you are inside quotes, which is most of a parser already, and doing it on substrings makes every nesting level rescan its contents. A cursor-based recursive-descent parser handles both problems for free, because each function only ever sees its own construct.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- Number of IslandsThe canonical connected-components question. Flood-fill each unvisited patch of land with BFS or DFS, count how many floods you started — grid traversal, visited bookkeeping, and the classic diagonal trap. Solve it in Python or TypeScript.Read →
- Merge IntervalsCalendar apps and memory allocators run on this: sort by start, sweep once, grow or close the current block. Touching endpoints merge, nested intervals vanish — the classic that punishes off-by-one thinking. Solve it in Python or TypeScript.Read →
- Top-K Frequent ElementsCount, then rank — the two-step pattern behind trending topics and hot-key detection. A hash map counts; a (-count, value) sort ranks with a clean tie-break; buckets get you to O(n) if you want the flex. Solve it in Python or TypeScript.Read →