Quick answer: Test JSON Schema string length at five boundaries: presence and type, inclusive limits, Unicode code points, transformation order, and downstream byte budgets. A field can pass a character limit while failing storage rules, or look like one character while containing several code points.
A customer name passes your browser counter but fails the API. The schema looks simple; the counting contract is not. These checks turn that disagreement into reproducible fixtures rather than another special case for emoji.
The examples target Draft 2020-12 and use Python's jsonschema validator. They test decoded field values, not the length of JSON source text. Start with the JSON Schema overview if schemas are new to you. Use the JSON Viewer to inspect sample payloads; it does not execute these schema assertions.
1. Does the field reject missing values and incorrect types?
Test property presence, value type, and string length independently. A length keyword does not require a field to exist and does not restrict non-string values by itself. Put required on the containing object, put type on the field schema, and include separate fixtures for omission, null, numbers, and empty strings.
Imagine a shipping integration that requires a recipient label. Setting a minimum length inside its property definition still leaves omission valid unless the surrounding object requires that member. An empty string, an absent property, and a null value are three different inputs; a useful regression suite gives each its own expected result.
The Draft 2020-12 core specification explains that type-specific assertions generally accept instances outside their target type. The separate type keyword supplies the restriction. This behavior is intentional and also supports fields that explicitly allow either text or null.
from jsonschema import Draft202012Validator
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"label": {"type": "string", "minLength": 1, "maxLength": 3}
},
"required": ["label"],
"additionalProperties": False,
}
Draft202012Validator.check_schema(schema)
validator = Draft202012Validator(schema)
for document, expected in [
({}, False), ({"label": None}, False), ({"label": 12}, False),
({"label": ""}, False), ({"label": "A"}, True),
]:
assert validator.is_valid(document) is expected
assert Draft202012Validator({"minLength": 1}).is_valid(12)
Keep failure categories distinct in your API response adapter. A missing field should not produce a misleading message about the number of characters in null. Our schema error-reporting tests cover mapping validator diagnostics into stable client-facing errors without depending on exact library message wording.
Also decide whether whitespace-only values are meaningful. A single space satisfies a minimum of one code point. If the business rule requires a nonblank label, define that rule explicitly rather than describing minimum length as proof that useful text exists. Do not silently trim opaque identifiers to make them pass.
2. Do exact limits pass while adjacent lengths fail?
Test one value below the minimum, each exact boundary, and one value above the maximum. JSON Schema minLength and maxLength are inclusive and require non-negative integer keyword values. Check the schema itself before testing instances so a malformed limit cannot be mistaken for an ordinary customer-data validation failure.
The validation specification's string assertions define both inclusive comparisons and valid keyword values. Begin with plain ASCII so Unicode handling cannot obscure an off-by-one error. A small field with limits two and four produces a readable test matrix that reviewers can verify without counting a long sample manually.
bounded = {"type": "string", "minLength": 2, "maxLength": 4}
Draft202012Validator.check_schema(bounded)
v = Draft202012Validator(bounded)
for text, expected in [
("A", False), ("AB", True), ("ABC", True),
("ABCD", True), ("ABCDE", False),
]:
assert v.is_valid(text) is expected
Repeat this matrix with your actual production limits. Generate the strings programmatically when the maximum is large, then keep the generated length in the assertion. Handwritten long strings are easy to miscount and harder to review when someone changes the contractual maximum in a later release.
Keep the instance matrix in version control beside the schema rather than relying on screenshots from an online validator. Give each case a name that describes the contract, such as exact maximum accepted or missing label rejected. When a fixture fails, print its case name and the failing keyword, with sensitive values omitted. This lets reviewers distinguish an intentional contract change from a broken adapter without interpreting an unrelated formatting diff.
Test zero deliberately if empty values are allowed. A maximum of zero accepts only the empty string among string instances; it does not forbid missing properties unless required is also present. Conversely, a minimum larger than the maximum can form a syntactically valid schema that no string satisfies. Meta-schema validation alone does not prove your contract is usable.
Review changes in both directions. Tightening a maximum can reject existing stored records or queued requests; relaxing it can let producers send values that an older consumer cannot store. Keep a representative accepted value at the old boundary and a new value near the proposed boundary in compatibility tests before updating shared API documentation.
3. Does the validator measure decoded Unicode code points?
Use a supplementary-plane emoji and a combining sequence to verify the counting unit. JSON Schema strings are sequences of Unicode code points, while JavaScript string length counts UTF-16 code units. Count decoded values after parsing; JSON escape spelling and quotation marks are not part of the field's schema length.
The official string reference introduces the length keywords, while the core data model defines strings as Unicode code points. A simple emoji is a useful detector for implementations or surrounding adapters that accidentally count UTF-16 units or encoded bytes instead.
import json
one = Draft202012Validator({"type": "string", "maxLength": 1})
emoji = json.loads('"\\ud83d\\ude00"')
assert len(emoji) == 1
assert one.is_valid(emoji)
assert one.is_valid("é")
assert not one.is_valid("e\u0301")
assert not one.is_valid("😀😀")
assert json.loads('"\\u0041"') == "A"
The decomposed accent contains a letter and a combining mark. It can appear as a single visual unit while measuring two code points. Wikipedia's background on grapheme clusters explains that terminology; the specification remains the authority for schema behavior. Visual appearance is not a reliable test oracle.
For a JavaScript frontend, MDN documents string length as UTF-16 code units. A code-point iterator can help build a counter aligned with this schema unit. It still does not count user-perceived grapheme clusters, so label the counter according to the contract rather than calling every measurement characters without explanation.
Add multilingual fixtures relevant to your users, including ordinary non-Latin text that must remain accepted. Keep emoji sequences with joiners separate from simple emoji. A test proving that one supplementary character passes does not establish that every visible emoji has length one, or that your runtime implements every Unicode operation identically to another service.
Run the corpus through the frontend counter and backend validator separately. If their measurements disagree, fix the named counting policy instead of weakening the server limit until one troublesome example passes. Include escaped and literal spellings of the same decoded value so a transport representation cannot accidentally influence acceptance. Keep malformed encoding tests in the parser layer; a field-length assertion does not replace a correctly configured decoder.
4. Does normalization happen at the documented validation boundary?
Test both the original and transformed value when your application normalizes or trims text. JSON Schema length validation does not perform those transformations for you. Choose the field's policy first, apply it consistently, and validate the representation that reaches storage so the accepted value and the persisted value follow the same contract.
Python's Unicode normalization documentation describes canonical composition and decomposition. Normalization Form C, or NFC, can turn a decomposed accent sequence into a composed character. That changes the code-point count in this example, making processing order observable at a maximum of one.
import unicodedata
original = "e\u0301"
normalized = unicodedata.normalize("NFC", original)
assert original != normalized
assert len(original) == 2 and len(normalized) == 1
assert not one.is_valid(original)
assert one.is_valid(normalized)
assert original == "e\u0301" # original value was not mutated
This is an application policy, not a recommendation to normalize every string recursively. Display names, search keys, external identifiers, and authenticated values can have different rules. Our Unicode comparison guide covers equivalence and transformation hazards beyond the schema assertion itself.
Write a pipeline fixture that records the final accepted value and compares it with the value sent to persistence. A unit test that normalizes only its expected result can hide a production path that stores the unnormalized input. Likewise, validating first and then trimming may produce an empty persisted value even though the original input satisfied the minimum.
When introducing normalization to an existing field, review stored data and consumer expectations before release. Do not present rewriting as a harmless display change. Preserve original input where the domain requires it, and keep raw-value validation available for contracts that prohibit transformations. Rejecting a value with an accurate explanation is preferable to silently changing an external identifier.
5. Does the accepted value fit the separate UTF-8 byte budget?
Add an explicit encoding-aware byte check when the receiving system specifies a byte limit. Schema string length and encoded size answer different questions. Test values that pass both rules, values that pass only the schema, and exact byte boundaries; do not assume a successful character check proves that downstream storage will accept the payload.
The schema data model evaluates decoded strings rather than an encoded transport representation. A byte budget therefore belongs in an additional application check. Name whether that budget covers one field's UTF-8 encoding, the whole serialized document, or an HTTP request body, because quotes, escaping, other fields, and framing change the latter measurements.
field_validator = Draft202012Validator({"type": "string", "maxLength": 3})
def accepted(text):
return field_validator.is_valid(text) and len(text.encode("utf-8")) <= 4
assert accepted("ABC")
assert accepted("😀") # one code point, four UTF-8 bytes
assert accepted("éé") # two code points, four UTF-8 bytes
assert field_validator.is_valid("ééé")
assert not accepted("ééé") # six UTF-8 bytes
assert not accepted("ABCD") # byte limit passes, schema limit fails
Run the byte check on the final representation after approved transformations. Include an encoding-error policy for malformed Unicode values instead of assuming every parser-produced string can be forwarded unchanged. The UTF-8 and BOM guide explains the separate transport concerns that a schema length rule cannot resolve.
Avoid automatically truncating a rejected value in the example acceptance path. Truncation changes the submitted data and needs its own business rule, especially for names, identifiers, and audit records. If a product deliberately offers shortening, show the proposed result before saving and test that the resulting representation satisfies every applicable limit. A successful shortened write does not prove the original value was accepted intact.
The Python examples were checked with jsonschema 4.26.0 using the documented validator API. They establish the shown outcomes, not full database or HTTP integration. Before release, run the same boundary corpus through your actual request handler and persistence adapter, then verify accepted values survive a read-back unchanged under the documented policy.
Keep the schema limits, counter unit, transformation order, and byte budget together in the contract. These five tests provide distinct failure signals: missing or wrongly typed data, off-by-one limits, wrong counting units, transformation mismatches, and downstream size rejection. That makes a failed build actionable without turning every international customer name into a manual support investigation.