grpc-ecosystem/grpc-gateway · error

JSON structure did not match request type

Error message

JSON structure did not match request type

What it means

FieldMaskFromRequestBody walks a JSON request body and the corresponding proto message descriptor in parallel to build a fieldmask path list. When it encounters a JSON object (map) to enqueue but the queue item's associated proto message is nil — i.e. the JSON nesting does not line up with the proto message structure — it returns "JSON structure did not match request type". This means the request body's shape does not correspond to the proto request message being decoded.

Solutions

  1. Make the JSON body exactly match the proto request message structure: every nested JSON object must correspond to a proto message field.
  2. Regenerate/re-fetch the client from the current proto definitions so field names and nesting line up.
  3. Flatten the body: remove extra nesting levels for fields that are scalars in the proto.
  4. Log the request body and compare with the message descriptor (e.g. via protoc --decode) to find the first divergent key.

Example fix

// before (proto: message { string name = 1; Sub sub = 2; } Sub { string x = 1; })
{"sub": {"x": {"nested": "oops"}}} // x is a scalar but body nests an object
// after
{"sub": {"x": "value"}}
Defensive patterns

Strategy: try-catch

Validate before calling

// client-side: verify body keys match proto fields before sending
fd := getFieldByName(msg.Descriptor().Fields(), jsonKey)
if fd == nil {
    return fmt.Errorf("key %q not present in %s", jsonKey, msg.Descriptor().FullName())
}

Type guard

func matchesMessageType(body map[string]interface{}, msg proto.Message) bool {
    fields := msg.Descriptor().Fields()
    for k, v := range body {
        fd := getFieldByName(fields, k)
        if fd == nil { return false }
        if m, ok := v.(map[string]interface{}); ok && fd.Kind() != protoreflect.MessageKind { return false }
    }
    return true
}

Try / catch

paths, err := runtime.FieldMaskFromRequestBody(r.Body, msg)
if err != nil {
    http.Error(w, fmt.Sprintf("invalid request body for %s: %v", msg.Descriptor().FullName(), err), http.StatusBadRequest)
    return
}

Prevention

When it happens

Trigger: POST/PATCH handlers generated by grpc-gateway (request_*Service_*_0 wrappers) call FieldMaskFromRequestBody when the HTTP body contains update_mask semantics; sending a JSON body whose nested objects do not match the proto message fields (e.g. an object where the proto expects a scalar, or extra nesting), or the field mask walk reaching an object whose corresponding field is not itself a message.

Common situations: Client sends mismatched JSON for the request type after API schema changes; protobuf field renamed/re-typed so JSON keys nest differently; manually crafting PATCH bodies with deeper nesting than the proto allows; using a wrapper/well-known type field where the walk expects a regular message.

Related errors


AI-assisted analysis of grpc-ecosystem/grpc-gateway@a58a4436a3 (2026-09-02). Data as JSON: /api/errors/63da52506e12c96d. Report an issue: GitHub.

Appendix: source

Thrown at runtime/fieldmask.go:48

		if errors.Is(err, io.EOF) {
			return fm, nil
		}
		return nil, err
	}

	queue := []fieldMaskPathItem{{node: root, msg: msg.ProtoReflect()}}
	for len(queue) > 0 {
		// dequeue an item
		item := queue[0]
		queue = queue[1:]

		m, ok := item.node.(map[string]interface{})
		switch {
		case ok && len(m) > 0:
			// if the item is an object, then enqueue all of its children
			for k, v := range m {
				if item.msg == nil {
					return nil, errors.New("JSON structure did not match request type")
				}

				fd := getFieldByName(item.msg.Descriptor().Fields(), k)
				if fd == nil {
					return nil, fmt.Errorf("could not find field %q in %q", k, item.msg.Descriptor().FullName())
				}

				if isDynamicProtoMessage(fd.Message()) {
					for _, p := range buildPathsBlindly(string(fd.FullName().Name()), v) {
						newPath := p
						if item.path != "" {
							newPath = item.path + "." + newPath
						}
						queue = append(queue, fieldMaskPathItem{path: newPath})
					}
					continue
				}

View on GitHub (pinned to a58a4436a3)