Up: (blog)
September 22, 2026
Everyone knows about JSON. As a software developer, you likely have to work with it once in a while. There are plethora of JSON parser libraries out there, but has it ever occurred to you that perhaps you don’t really need one?
That’s what I’ve been wondering lately, when maintaining a personal software project. One feature involves processing the JSON output of another application. It’s a small feature, but introduces a JSON library as dependency. This has always been a thorn in the flesh, because I’d like to keep the codebase as simple as possible, which includes dependencies—Never add a dependency unless it’s absolutely necessary.
I’ve already put some efforts into eliminating dependencies that could have been avoided.1 But the removal of the JSON library was not seriously considered until recently, because implementing a JSON parser does not seem as trivial as something like Base64.
Well, I stand corrected. A JSON parser could be very simple or very complicated, depending on what you expect from it. Most of the time, you don’t benefit much from the extra complexity—quite the contrary, you’re more likely to suffer from obscure bugs, or worse, vulnerabilities2.
Strict RFC 8259 conformance is a core feature of most JSON parsers, yet arguably the least useful one.
Do you want it to complain about trailing commas in arrays or leading zeroes in numbers? Not really. Checking for bad values like ‘undefined’, or whether a string is valid UTF-8? Maybe, but such checks are better handled by the application.
Also note that enforcing standard conformance is not without cost. Let’s say you just finished parsing a JSON array, what comes next depends on whether this array is within a JSON object, another array, or top-level. To obtain this information, you need to maintain a stack of some sort, and that in turn adds complexity and limitations to the implementation.
In fact, a JSON parser don’t have to care about strict conformance. It’s okay to assume that the JSON input is well-formed, and let the caller handle the rest—as long as bad input does not result in vulnerability.
We know that the JSON format is derived from JavaScript’s object literal.
It can also perfectly fit into the built-in data types of most other
dynamically-typed programming languages, such as PHP, Python and Lua.
For such languages, the parser API can be just a simple
val = JSON.parse(str).
This is not the case for languages like C/C++. Instead, JSON libraries usually provide a “DOM-style API”. Take the following code snippet (using Jansson) for example:
char const *str = "[{\"name\":\"foo\"},{\"name\":\"bar\"}]"; json_t arr = json_loads(str, 0, NULL); json_t obj = json_array_get(arr, 1); json_t name = json_object_get(obj, "name"); char const *name_str = json_string_value(name); /* "bar" */
You may find some merits in such API when the JSON input is arbitrarily
structured (e.g., when implementing something like jq),
but otherwise we’d prefer to parse it into structs and access values directly
with arr[1]->name.3
In both cases, a lexer API (or so-called “SAX-style API”) that outputs a stream of JSON tokens is sufficient. And unlike DOM-style API, it’s free of the extra hassle required for implementation, such as hashtables, memory pools, reference counters, etc.4
A few JSON libraries (notably yyjson and simdjson) boast high performance as a major feature, which can be 10x faster than a naïve parser.
For example, when parsing a ~64MiB JSON file on my PC:5
| Library | Time taken (ms) |
|---|---|
| Jansson | 1342 |
| cJSON | 630 |
| LJSON | 364 |
| RapidJSON | 218 |
| simdjson | 138 |
| yyjson | 121 |
The actual JSON payload—be it an HTTP request/response, or a config file to be loaded at program startup—would be much smaller, to the point that parsing overhead should usually be on sub-millisecond level, even if using a slow parser like Jansson. There would be little to gain to optimize this, especially for I/O bound workloads in web applications, where JSON is mostly used.
So nope. As much as I admire the skills of the developers who created these powerful tools, I’m not a big fan of using them in production, unless the profiler proves it worthwhile.
Later in this article, I’m going to present a working example to demonstrate how simple a JSON parser can be. Before that, I’d like to show you something else.
NOSJ (NOt Strictly Json) is a JSON dialect meant for building non-validating JSON parsers with simplicity in mind. It’s syntactically a JSON superset while semantically a subset.
I’m not interested in writing a formal spec for it, but here’s the grammar in ABNF:
nosj-value = nosj-string / nosj-bare / nosj-array
nosj-string = json-string ; see RFC 8259 section 7
nosj-bare = 1*( ALPHA / DIGIT / %x7F-FF
/ "!" / "#" / "$" / "&" / "'" / "*" / "+" / "-"
/ "." / "/" / ";" / "<" / "=" / ">" / "?" / "@"
/ "\" / "^" / "_" / "`" / "|" / "~" )
nosj-array = ( "{" / "[" )
*( nosj-separator nosj-value ) nosj-separator
( "}" / "]" )
nosj-separator = *( %x00-1F / " " / ":" / "," )
Notable differences compared to standard JSON:
Unless quoted, whitespaces do not affect the semantics of a JSON text.
Separators are usually no different.
For example, stripping away the separators from JSON text
["foo",["bar","baz"]] makes it ["foo"["bar""baz"]].
The latter can still be parsed without ambiguity.
The only exception to this is when non-quoted primitive values are adjacent,
e.g., [1,2,3], in which case the separators are necessary.
See also this blog post for a similar idea.
There’s no point distinguishing between an “object” and an “array” from a parser’s perspective. They are just a list of values, and it’s up to the application to decide how to interpret them.
This includes numbers, ‘true’, ‘false’ and ‘null’ in JSON. They are no different from a string, apart from not being quoted.
In NOSJ we call it “bare” strings, where most character sequences are
allowed.
The application can then parse it with something like strtod(3) or
strcmp(3), depending on what that value is supposed to mean.
Here’s a “reference implementation” in less than 150 lines of C99 code, no libc functions used:6
#ifndef NOSJ_H_ #define NOSJ_H_ #include <sys/types.h> #define NOSJ_VAL_BARE 1 #define NOSJ_VAL_STRING 2 #define NOSJ_VAL_ARRAY_BEGIN 3 #define NOSJ_VAL_ARRAY_END 5 struct nosj_parse_ctx { char const *str; char const *end; size_t depth; }; struct nosj_value { char const *str; size_t len; size_t unesc_len; /* NOSJ_VAL_STRING only */ }; int nosj_parse_next (struct nosj_parse_ctx *ctx, struct nosj_value *val); ssize_t nosj_string_unescape (char *dst, char const *src, size_t src_len); #endif /* !defined(NOSJ_H_) */
#include "nosj.h" #include <stddef.h> #include <stdint.h> static char const * parse_bare (char const *str, char const *end, struct nosj_value *val) { // 0x00-0x1f, ' ', ',', ':', '{', '}', '[', ']', '"' static uint64_t const lut[4] = { 0x04001005ffffffff, 0x2800000028000000 }; val->str = str - 1; for (; str < end; ++str) { unsigned char ch = *str; if (lut[ch / 64] & (UINT64_C(1) << (ch % 64))) { break; } } val->len = str - val->str; return str; } static char const * parse_string (char const *str, char const *end, struct nosj_value *val) { // '"', '\\', '/', 'b', 'f', 'n', 'r', 't' static uint64_t const lut[4] = { 0x0000800400000000, 0x0014404410000000 }; val->str = str; for (size_t esc_cnt = 0; str < end; ++str) { unsigned char ch = *str; if (ch == '\\') { if (str == end) { break; } ch = *(str++); if (!(lut[ch / 64] & (UINT64_C(1) << (ch % 64)))) { break; } ++esc_cnt; } else if (ch == '"') { val->len = str - val->str; val->unesc_len = val->len - esc_cnt; return str + 1; } else if (ch < 0x20) { break; } } return NULL; } int nosj_parse_next (struct nosj_parse_ctx *ctx, struct nosj_value *val) { // 0x00-0x1f, ' ', ',', ':' static uint64_t const lut[4] = { 0x04001001ffffffff }; char const *str = ctx->str; char const *end = ctx->end; unsigned char ch; for (; str < end; ++str) { ch = *str; if (!(lut[ch / 64] & (UINT64_C(1) << (ch % 64)))) { break; } } if (str == end) { return ctx->depth > 0 ? -1 : 0; } ++str; if (ch == '"') { str = parse_string(str, end, val); if (str == NULL) { return -1; } } else if (ch == '[' || ch == '{') { ++ctx->depth; } else if (ch == ']' || ch == '}') { if (ctx->depth-- == 0) { return -1; } } else { str = parse_bare(str, end, val); ch = 1; } ctx->str = str; return ch & 7; // NOSJ_VAL_* } ssize_t nosj_string_unescape (char *dst, char const *src, size_t src_len) { // XXX: '\uXXXX' escape is not supported. static char const lut[256] = { ['"'] = '"', ['\\'] = '\\', ['/'] = '/', ['b'] = '\b', ['f'] = '\f', ['n'] = '\n', ['r'] = '\r', ['t'] = '\t', }; size_t dst_len = 0; for (char const *end = src + src_len; src < end; ) { char ch = *(src++); if (ch == '\\') { if (src == end) { return -1; } ch = lut[(unsigned char)*(src++)]; if (ch == 0) { return -1; } } dst[dst_len++] = ch; } return dst_len; }
Trivia: when a friend finished reading this article, he pointed out that the idea of NOSJ closely resembles that of jsmn’s initial implementation. Well, I guess it’s not a surprising find, since JSON is simple enough such that there’s not much room to simple it further.
Emacs switched from Jansson to its own JSON implementation since Emacs 30. This change was meant for better performance, yet simple and straightforward nonetheless.
There’s also snac’s JSON parser. It’s part of the xs utilities shared among the
author’s software projects.
Indeed. Since we’re talking about less dependencies, if your programming language provides a JSON parser in its standard library, there’s no reason not to use it instead.
However, be aware that such languages tend to have an ecosystem that suffers more from dependency bloat. A fancy JSON parser is probably the last thing you’d worry about.
I’d recommend /ˈnuːɧ/, but you can pronounce it whatever you like :)
e.g., Implementing bin2hex/hex2bin conversion for RFC 4122 UUIDs, so that libuuid is no longer required; Implementing Base64/Base16 encoding, to get rid of Nettle; …
In late July, a former colleague complained on social media about having to stay up late to mitigate another RCE in fastjson. That’s partly what motivates me to write this blog post.
Some JSON parsers do offer this feature, for example, Golang standard library’s
json.Unmarshal
associates JSON value to its corresponding struct field by using reflection;
similar can be achieved in microjson
by manually define the mappings.
Your application may still need those, and it is the application that should implement them, rather than the JSON parser.
Parsing otfcc.json on an Intel Core
Ultra 7 255H, using the latest release of libraries built with -O2.
The code is licensed under the 2-Clause BSD License.