Sanity API versions not returning key except v1

35 replies
Last updated: Mar 31, 2022
every version other than v1 which doesn’t return the key at all.
AI Update

Based on the context, this statement is part of a discussion about how different Sanity API versions handle empty or unset fields in GROQ queries.

The key difference:

  • API v1: Completely omits fields from the response when they're empty or unset - the key doesn't appear at all in the returned JSON
  • API v2 and later versions (like v2021-03-25): Return null for empty/unset fields when you explicitly request them in a projection

Why this happens:

When you write a GROQ query with a projection like *[]{_id, type->name}, you're explicitly asking for those fields. In API versions after v1, GROQ honors this request by including the key in the response even when the value doesn't exist - but it sets it to null rather than omitting it entirely.

This is particularly relevant when:

  • A user clears a string field in Sanity Studio (the field gets removed from the document entirely, not set to an empty string)
  • You're querying referenced documents where fields might not be set
  • You're migrating from v1 to newer API versions

Solutions to handle this:

  1. Use the coalesce() function to provide fallback values:

    *[_type == 'movie']{name, 'rating': coalesce(rating, '')}
  2. Add validation rules to make fields required and prevent them from being cleared

  3. Handle null values in your application code when consuming the API response

The original discussion thread also mentions an interesting edge case where the Vision plugin showed correct values while production API calls returned null - this was resolved by adding the specific projection to the webhook query, likely due to API version differences between the two contexts.

Show original thread
35 replies
user H
, I don't believe this is a bug actually! In some ways, its probably more of an implementation thing for GROQ. When reading the documentation under the Filters Section , you notice that a key will be null when it does not exist. Thus, any version but v1 is returning null simply because the key does not exist, not because of an empty string or anything like that.
My guess would be that the implementation of the input component for strings will remove the value on an empty string. This in some ways is more consistent: falsey values will always unset the key rather than giving a default empty value like "" or [] or {} or 0.
But the key exists .. as if I use type->{_id, name} I get the _id and NULL for name .. so perhaps it is how GROQ (mis)handles the type. But if the field is empty I expect a string type definition to return ‘’, not NULL.
The query that was problematic was type->name return null.
You are defining the key through your projection. That's why it is null!
Try doing type-> and my guess would be that name would not appear at all
This then produced an update problem, even after the key was fixed. It was still returning null
As you said above, removing falsey values was the behaviour of API V1, but in subsequent versions it returns NULL on falsey or empty.
I guess we can get religious quickly on the topic of whether an empty string is falsey. 🙂
The way GROQ works is all about projections. Here are examples that may help:
Here is a projection that asks for all defined fields on a GROQ query for all movies

*[_type == 'movie']{...}
Let's say I don't want all of them, I just want the name of the movie and its rating.

*[_type == 'movie']{name, rating}
Great, that's solved. However, rating is not a required field, so it sometimes is not set. What should GROQ do? If rating is a string, it could give an empty string. Or maybe if rating is a number, it could be a -1. Based on the type and what you want, there would be all sorts of defaults. To get around this, GROQ has decided that it will unset the property if it is not set.

BUT, and here's the key, your query has asked to return the rating. So GROQ wants to honor this request, and has decided to return null on all unset values.
However, if you don't like this kind of behavior, there is a solution in the form of the
coalesce
function.

*[_type == 'movie']{name, 'rating': coalesce(rating, '')}
OK, I see. I understand the choices made here.
What I observed is that even after this user error was fixed, The API continued to return NULL. We are reviewing the logs to understand why the relationship was broken
Glad I could help! You are right though: v1 more closely represents your actual data. All other versions add this functionality on top of it which can be slightly confusing without knowledge of the reasons behind it.
And even more frustrating was that the vision plugin was returning the result correctly, even as calls from the production stack was getting back the wrong value. (I did ensure that I wasn’t talking to the CDN API, even though this would have healed up on subsequent publishes)
Ooh, so you were getting an empty string on the vision plugin but in production it was null? That is frustrating!
actually not an empty string .. but I was getting the correct string value back on the vision call, but on the stack API call, the exact same query, I was getting NULL. Yep.
So, my solution, sharing this naïvely, in case others have this edge case..
I am using webhooks to notify my production stack of updates in Sanity. As the webhook has a (unknown) payload limit, I have to call back to the Sanity API to get the update payload. On this call, it was returning NULL.So, I added the specific GROQ projection into the Webhook query triggered on UPDATE. When this projection was in that query. The API returned the key correctly. I can’t think of why.
And this whole case came about because a user cleared the string field in a type definition document linked to the requested document.
user G
- thank you!!If I’m following correctly, this may be the difference between a string type field that exists but is empty and a string type field that doesn’t exist in the document? If I delete a native string type field’s contents, the field itself is removed from the query unless I’ve done something like an empty initialValue or alter this with a custom component.


{
  "_createdAt": "2022-03-02T22:54:33Z",
  "_id": "5b5333e7-5fdf-4b28-81df-b9d0553d9a39",
  "_rev": "0stphb-jyu-jjg-kg1-mdjdh5f7b",
  "_type": "tag",
  "_updatedAt": "2022-03-30T03:58:52Z",
  "icon": "B", // <-- string
  "title": "badger"
}
// icon field emptied, item is removed
{
  "_createdAt": "2022-03-02T22:54:33Z",
  "_id": "5b5333e7-5fdf-4b28-81df-b9d0553d9a39",
  "_rev": "5k4d2t-snm-s0d-jp3-hb3g2tmuc",
  "_type": "tag",
  "_updatedAt": "2022-03-31T18:35:52.753Z",
// no "icon"
  "title": "badger"
}

Hi
user U
Thanks for your comment. It feels arbitrary to remove a known typed field from a response. We are compensating for this design choice. I agree that it is an improvement to return a NULL instead of V1's choice to unset the property.
And just for clarity. We are talking about a reference to an object definition and not a self contained property. The reference object has
{_id: "ID", name: "" }
so if I GROQ to that with
*[]{_id, type->{}}
It may be possible to do this by altering handlechange in a custom string component:
import React from 'react'
import { FormField } from '@sanity/base/components'
import { TextInput } from '@sanity/ui'
import PatchEvent, { set, unset } from '@sanity/form-builder/PatchEvent'

const MyStringInput = React.forwardRef((props, ref) => {
  const {
    type,
    value,
    readOnly,
    placeholder,
    markers,
    presence,
    compareValue,
    onFocus,
    onBlur,
    onChange
  } = props

  const handleChange = React.useCallback(
    (event) => {
      const inputValue = event.currentTarget.value
      // if new input value, add input value, else don't...
      onChange(PatchEvent.from(inputValue ? set(inputValue) : unset()))
    },
    [onChange]
  )

  return (
    <FormField
      description={type.description}
      title={type.title}
      compareValue={compareValue}
      __unstable_markers={markers}
      __unstable_presence={presence}
    >
      <TextInput
        value={value}
        readOnly={readOnly}
        placeholder={placeholder}
        onFocus={onFocus}
        onBlur={onBlur}
        // add onChange to input
        onChange={handleChange}
      />
    </FormField>
  )}
)

export default MyStringInput

I’m not certain off the top of my head if an empty string or other fn could be placed in this onChange ternary, but it might be worth a shot
onChange(PatchEvent.from(inputValue ? set(inputValue) : unset()))
Wow, this is super nice of you. Thank you. The better point for our team on this was to restrict the UI more and not let low level users into some of the definition objects. This is what I have turn my attention to.
This is pasted from my personal notes, please forgive any typos.
The API inconsistencies that followed were really hard to understand. The design choices for GROQ, once I could understand them. I can design around the expected behaviour.
But the weird stickiness of this projection on the API until I added the projection to the webhook query is still a mystery to me.
A strictly native option if you want to ensure this isn’t empty might be to add validation .
For building things and making sure traffic is minimized, I’m enjoying groq quite a bit. GraphQL is another fantastic and supported option. That unset appears to be part of the native input options though - which should be capable of being modified.
I am still very much in learning mode myself, and if you need anything further I’d be happy to run things past the team as well.
Yeah, my experience is with Neo4J, but I am warming to GROQ and modelling an admin in Studio is a HUGE time win. Also, the general speed of update is super impressive. So when something unexpected happens, you’re really not expecting it. 🙂

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?