Skip to content

Option to sort JSON output alphabetically #2848

Open

Description

@patkovskyi

Problem solved by the feature

Caching. A common pattern is to generate a request body using Gson. Another common pattern is to cache the response by using a hash of the request body as a cache key. Gson does not guarantee stable JSON order for some objects (notably, ImmutableSet/ImmutableMap), leading to non-deterministic cache keys for essentially the same requests.

Testing. If you want to test the serialization of object trees that contain ImmutableSet/ImmutableMap, you can't write string assertions now because Gson output is non-deterministic.

Feature description

new GsonBuilder().sortOutputAlphabetically().create() — serialize all JSON keys alphabetically. Deserializer works as before.

Maybe a better option would be something like: new GsonBuilder().setOutputOrdering(ALPHABETICAL).create()

Alternatives / workarounds

  1. Avoid using ImmutableSet/ImmutableMap. Unfortunately, you often have to serialize other teams' objects (sometimes coming from other libraries), making this recommendation impractical.
  2. Write your TypeAdapters. Hard, error-prone, and time-consuming. I'm still struggling to make it work. Would appreciate an example that enforces Set and Map ordering.

Activity

  1. changed the title [-]Sort JSON output alphabetically[/-] [+]Option to sort JSON output alphabetically[/+] on Apr 17, 2025
  2. Marcono1234 commented on Apr 18, 2025

    @Marcono1234
    Contributor

    For Set (or generally Collection) it might be a bit more difficult because they are serialized as JSON array and could contain non-String values. The question is how they should be handled.

    Also, maybe for better performance users might want to select in which cases this sorting should be performed. If the Set or Map implementation already has a well defined iteration order, then it might be redundant to sort their entries. And the performance overhead is non-negligible (especially when using streaming serialization to a Writer), because all entries have to be collected first to be sorted before they can be written.


    Gson does not guarantee stable JSON order for some objects (notably, ImmutableSet/ImmutableMap)

    Note that Guava has the subclasses ImmutableSortedSet and ImmutableSortedMap. Gson just serializes the entries in iteration order, so if the Set or Map has a well defined iteration order, then that is preserved.

    Side note: Unless you registered custom adapters (either your own, or from an external library) for ImmutableSet/ImmutableMap you might run into issues in Gson 2.13.0, see the release notes. Related: #2742


    Workarounds
    [...]
    Write your TypeAdapters. Hard, error-prone, and time-consuming. I'm still struggling to make it work. Would appreciate an example that enforces Set and Map ordering.

    The easiest way to implement this might be to write a TypeAdapterFactory which delegates to your existing Map and Set adapters to create an in-memory JsonElement tree, and afterwards sorts it before writing it.

    For example:

    (click to expand / collapse)
    class SetSortingSerializationFactory implements TypeAdapterFactory {
      public static final SetSortingSerializationFactory INSTANCE = new SetSortingSerializationFactory();
    
      private SetSortingSerializationFactory() {}
    
      @Override
      public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
        // Only handle Set implementations; for all other types use other registered adapters
        // Can make this more specific if desired, e.g. by checking `MySet.class` instead of any `Set.class`
        if (!Set.class.isAssignableFrom(type.getRawType())) {
          return null;
        }
    
        TypeAdapter<JsonElement> jsonElementAdapter = gson.getAdapter(JsonElement.class);
        TypeAdapter<T> delegateAdapter = gson.getDelegateAdapter(this, type);
        return new TypeAdapter<T>() {
          // For now only support JSON string values
          private String getAsString(JsonElement json) {
            JsonPrimitive jsonPrimitive = json.getAsJsonPrimitive();
            if (!jsonPrimitive.isString()) {
              throw new IllegalArgumentException("Expected JSON string but was: " + jsonPrimitive);
            }
            return jsonPrimitive.getAsString();
          }
    
          @Override
          public void write(JsonWriter out, T value) throws IOException {
            JsonElement json = delegateAdapter.toJsonTree(value);
            // Can only handle JSON array, leave other JSON values unchanged
            if (json.isJsonArray()) {
              JsonArray jsonArray = json.getAsJsonArray();
              Comparator<String> comparator = Comparator.naturalOrder();
              // Sort existing `jsonArray` through its List view
              jsonArray.asList().sort(Comparator.comparing(this::getAsString, comparator));
            }
    
            jsonElementAdapter.write(out, json);
          }
    
          @Override
          public T read(JsonReader in) throws IOException {
            return delegateAdapter.read(in);
          }
        };
      }
    }
    
    class MapSortingSerializationFactory implements TypeAdapterFactory {
      public static final MapSortingSerializationFactory INSTANCE = new MapSortingSerializationFactory();
    
      private MapSortingSerializationFactory() {}
    
      @Override
      public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
        // Only handle Map implementations; for all other types use other registered adapters
        // Can make this more specific if desired, e.g. by checking `MyMap.class` instead of any `Map.class`
        if (!Map.class.isAssignableFrom(type.getRawType())) {
          return null;
        }
    
        TypeAdapter<JsonElement> jsonElementAdapter = gson.getAdapter(JsonElement.class);
        TypeAdapter<T> delegateAdapter = gson.getDelegateAdapter(this, type);
        return new TypeAdapter<T>() {
          @Override
          public void write(JsonWriter out, T value) throws IOException {
            JsonElement json = delegateAdapter.toJsonTree(value);
            // Can only handle JSON object, leave other JSON values unchanged
            if (json.isJsonObject()) {
              JsonObject jsonObject = json.getAsJsonObject();
              List<String> keys = new ArrayList<>(jsonObject.keySet());
              Comparator<String> comparator = Comparator.naturalOrder();
              keys.sort(comparator);
    
              JsonObject newObject = new JsonObject();
              // Insert sorted entries; JsonObject preserves insertion order
              for (String key : keys) {
                newObject.add(key, jsonObject.get(key));
              }
              json = newObject;
            }
    
            jsonElementAdapter.write(out, json);
          }
    
          @Override
          public T read(JsonReader in) throws IOException {
            return delegateAdapter.read(in);
          }
        };
      }
    }

    And then register them like this:

    Gson gson = new GsonBuilder()
      // Register all other adapters and factories first
      // ...
      // Register the sorting factories last, because they delegate to the other adapters
      .registerTypeAdapterFactory(SetSortingSerializationFactory.INSTANCE)
      .registerTypeAdapterFactory(MapSortingSerializationFactory.INSTANCE)
      .create();
    
    Set<String> mySet = new LinkedHashSet<>(Arrays.asList("b", "c", "a"));
    System.out.println(gson.toJson(mySet));
    
    Map<String, Object> myMap = new LinkedHashMap<>();
    myMap.put("b", 1);
    myMap.put("c", Arrays.asList(1, 2));
    myMap.put("a", true);
    System.out.println(gson.toJson(myMap));

    Most likely you want to adjust the Set.class and Map.class checks in the #create methods to be more specific and only handle the Set and Map implementations which really need sorting. Otherwise this decreases performance by redundantly sorting the JSON output for other Set and Map implementations.

    (Note: I have not extensively tested this, and maybe it can be refactored to improve performance. But it should work as desired.)

  3. patkovskyi commented on Apr 23, 2025

    @patkovskyi
    Author

    Thank you very much for a prompt and thoughtful reply. I'll study and test your workaround with TypeAdapterFactories.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions