Specification scope: Core only.

Tool: Structurize is the JSON Structure-focused command-line interface and conversion toolkit from the Avrotize project.

A week of shipment records contains carrierService in every row and only three status values. That is evidence of what the exporter produced during that week. It is not a contract that makes the field mandatory or limits the lifecycle to those three states.

Structurize’s json2s command turns those observations into a draft JSON Structure schema. Raw records cannot decide whether a string is a UUID, whether an absent field is optional, or whether observed values form a closed enum. Those decisions require producer guarantees and a compatibility policy.

Reviewers express the decisions with JSON Structure types, required, enums, and choices. Structurize can then project the reviewed schema into documentation and code. Valid observations remain covered; accidental requiredness, incomplete enums, and unjustified type guesses are corrected or discarded before they escape into generated artifacts.

The examples use Structurize 3.9.0. Install the pinned release from PyPI:

python -m pip install structurize==3.9.0

Begin with what happened

Assume a fulfillment team has collected newline-delimited JSON from a dispatch feed:

{"orderId":"c28788fa-73bd-4b39-a209-2f75f82c6653","status":"allocated","carrierService":"priority-ground","dispatchAt":"2026-08-25T16:30:00Z","contact":"ops@example.com"}
{"orderId":"9b51a282-d3dd-476b-a7d7-97ace6318312","status":"dispatched","carrierService":"priority-ground","dispatchAt":"2026-08-26T09:15:00Z","contact":"dock@example.com"}
{"orderId":"dd4e44b5-aae8-4c40-8c57-5535d66e7b9d","status":"allocated","carrierService":"economy","contact":"night@example.com"}

Structurize 3.9.0 can infer a JSON Structure schema from JSON or JSONL. Its CLI definition lists the controls for input scope and inference:

structurize json2s dispatch.jsonl \
  --out shipment-observed.struct.json \
  --type-name ShipmentObservation \
  --base-id https://example.com/schemas/ \
  --sample-size 10000 \
  --infer-enums

--sample-size limits the records considered. --infer-enums permits enum inference from observed value cardinality. For heterogeneous documents, --infer-choices and --choice-depth control choice inference. Those switches change the analysis. They do not turn the resulting file into an approved contract.

For these three records, Structurize 3.9.0 emits this schema:

Generated output: shipment-observed.struct.json
{
  "$schema": "https://json-structure.org/meta/core/v0/#",
  "$id": "https://example.com/schemas/ShipmentObservation",
  "type": "object",
  "name": "ShipmentObservation",
  "properties": {
    "carrierService": {
      "type": "string"
    },
    "contact": {
      "type": "string"
    },
    "dispatchAt": {
      "type": "datetime"
    },
    "orderId": {
      "type": "string"
    },
    "status": {
      "type": "string"
    }
  },
  "required": [
    "carrierService",
    "contact",
    "orderId",
    "status"
  ]
}

No enum is inferred from only three records, dispatchAt is optional because it is absent once, and UUID- and email-shaped values remain strings.

The earlier introduction to json2s covers merging, choices, clustering, and basic limitations. The next problem is more important: deciding what the observations mean.

Presence is not obligation

json2s derives requiredness from observed presence and nulls. A property becomes required only when it is present in every sample and no observed value is null. One missing or null value keeps it out of required.

That is a faithful statement about the input. It can still be wrong for the contract.

In the three records above, orderId, status, carrierService, and contact are always present. dispatchAt is absent once. The sample therefore supports a presence distinction, but it provides no reason for that distinction. Perhaps dispatchAt is valid only after dispatch. Perhaps one producer omitted a mandatory value. Perhaps carrier assignment will become asynchronous next month, making carrierService temporarily unavailable.

Review each inferred required entry against a lifecycle rule or an API guarantee. Also review every omission. Frequency tells you where to ask; domain policy supplies the answer.

Strings conceal several decisions

UUID-, URI-, email-, and Base64-like values remain strings during inference. That restraint is valuable. A string that happens to parse as a UUID in the sample may be an opaque identifier whose future syntax is wider. An email-like contact may permit a queue name later. Base64-looking text may simply be text.

When the domain guarantee is real, promote the declaration deliberately:

{
  "orderId": {
    "type": "uuid",
    "description": "Stable identifier assigned when the order is accepted."
  },
  "contact": {
    "type": "string",
    "description": "Fulfillment contact address or routing name."
  }
}

uuid is justified here by the order identity contract, not by three matching lexical forms. contact stays a string because its domain is intentionally broader than email syntax.

The same review applies to binary data. Declaring binary carries decoding semantics, and annotations such as contentEncoding describe a processing contract. A run of plausible characters does not establish either one.

Temporal shape needs a promise

Temporal inference requires all observed non-null values for the field to be strings. It selects datetime, date, or time when at least 80% of those strings match that one temporal shape; otherwise, absent enum inference, the field remains a string.

A threshold protects the analysis from treating every date-like accident as a type declaration, but it cannot answer the domain question. dispatchAt can be a datetime only if the producer promises an RFC 3339 date-time with an offset. Historical consistency is evidence for that promise, not the promise itself.

This edit makes the commitment explicit:

"dispatchAt": {
  "type": "datetime",
  "description": "Time at which the shipment left the fulfillment site."
}

The missing third value still requires a presence decision. Type and requiredness answer different questions.

Small value sets are not automatically enums

With --infer-enums, enum inference requires at least five observed string values, 2 through 50 unique values, and a unique-to-sample ratio no greater than 10%. It also rejects values that look like UUIDs, structured IDs, timestamps, strings longer than 50 characters, long paths or URLs, or numeric strings. A candidate that passes those lexical and cardinality filters is still not proof that the observed values form a closed set.

The feed currently shows allocated and dispatched. The fulfillment process may also define cancelled, held, and returned, even if none occurred in the selected records. Publishing an enum with only the observed pair would reject valid future messages.

If the lifecycle owns a closed set, declare the full domain:

"status": {
  "type": "string",
  "enum": ["allocated", "dispatched", "cancelled", "held", "returned"]
}

If producers may add states independently, keep a string or establish an evolution policy before closing the set. The right answer comes from ownership and compatibility requirements, not a cardinality cutoff.

Review the draft as a contract change

A practical inference workflow has distinct stages:

  1. Run json2s against a named, reproducible sample set and retain the command options with the review.
  2. Compare inferred presence, primitive types, temporal candidates, enums, and choices with producer guarantees and consumer requirements.
  3. Add stable names, descriptions, constraints, and domain types that the data alone cannot establish.
  4. Validate the source samples against the edited schema. Passing confirms coverage of those observations; it says nothing about unobserved valid cases.
  5. Publish the reviewed JSON Structure document as the source contract, with normal versioning and ownership.

Then project artifacts from that contract:

New-Item -ItemType Directory -Force generated | Out-Null
structurize s2md shipment.struct.json --out generated/shipment.md
structurize s2cs shipment.struct.json --out generated/dotnet --namespace Fulfillment.Contracts

For complex code generation, the prebuilt Avrotize Inventory to C# gallery example shows the complete source schema and generated project. One representative output file from that gallery example is:

Generated output: CategoryEnum.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Csharp
{
  /// <summary>
  /// CategoryEnum
  /// </summary>
  [System.Text.Json.Serialization.JsonConverter(typeof(CategoryEnumConverter))]
  public enum CategoryEnum
  {
    Electronics,
    Clothing,
    Food,
    Furniture,
    Toys,
    Books,
    Other
  }

  /// <summary>
  /// System.Text.Json converter for CategoryEnum that maps to schema values
  /// </summary>
  public class CategoryEnumConverter : System.Text.Json.Serialization.JsonConverter<CategoryEnum>
  {
    /// <inheritdoc/>
    public override CategoryEnum Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options)
    {
      var stringValue = reader.GetString();
      return stringValue switch
      {
        "electronics" => CategoryEnum.Electronics,
        "clothing" => CategoryEnum.Clothing,
        "food" => CategoryEnum.Food,
        "furniture" => CategoryEnum.Furniture,
        "toys" => CategoryEnum.Toys,
        "books" => CategoryEnum.Books,
        "other" => CategoryEnum.Other,
        _ => throw new System.Text.Json.JsonException($"Unknown value '{stringValue}' for CategoryEnum")
      };
    }

    /// <inheritdoc/>
    public override void Write(System.Text.Json.Utf8JsonWriter writer, CategoryEnum value, System.Text.Json.JsonSerializerOptions options)
    {
      var stringValue = value switch
      {
        CategoryEnum.Electronics => "electronics",
        CategoryEnum.Clothing => "clothing",
        CategoryEnum.Food => "food",
        CategoryEnum.Furniture => "furniture",
        CategoryEnum.Toys => "toys",
        CategoryEnum.Books => "books",
        CategoryEnum.Other => "other",
        _ => throw new System.ArgumentOutOfRangeException(nameof(value))
      };
      writer.WriteStringValue(stringValue);
    }
  }
}

Samples record observed values. Inference summarizes those observations. Only the reviewed schema states what producers may send and consumers must accept.