GraphQL Schema and Types
The schema is the contract. Written in SDL (Schema Definition Language) — a small DSL for describing types, fields, inputs, and operations.
Core building blocks
type User { # object type
id: ID! # field with non-null scalar
name: String!
email: String
age: Int
isActive: Boolean!
balance: Float
joinedAt: DateTime # custom scalar
role: Role! # enum
posts: [Post!]! # list of non-null Posts; list itself non-null
manager: User # nullable self-reference
}
| Means | |
|---|---|
Int, Float, String, Boolean, ID |
built-in scalars |
! |
non-null (required) |
[T] |
list of T (nullable) — could be null or empty |
[T!] |
list of non-null T — can be null but no null elements |
[T]! |
non-null list — empty list is OK; null is not |
[T!]! |
non-null list of non-null T — must be a non-empty-allowed list with no null elements |
Built-in scalars
| Scalar | Maps to |
|---|---|
Int |
32-bit signed integer |
Float |
double-precision float |
String |
UTF-8 |
Boolean |
true / false |
ID |
opaque string identifier (often UUID or DB primary key) |
Custom scalars
scalar DateTime
scalar JSON
scalar URL
scalar UUID
The schema declares them; resolvers handle serialization. Most libraries provide common scalars (DateTime, UUID, EmailAddress) — install rather than reinvent.
In Strawberry:
import datetime
import strawberry
from strawberry.scalars import JSON
@strawberry.type
class Event:
id: strawberry.ID
occurred_at: datetime.datetime # built-in mapping to DateTime scalar
metadata: JSON # arbitrary JSON
Enums
enum Role {
ADMIN
EDITOR
VIEWER
}
Enums are exhaustive at the schema level — adding a value is a breaking change for clients that exhaustively switch on them.
In Strawberry:
import enum
import strawberry
@strawberry.enum
class Role(enum.Enum):
ADMIN = "ADMIN"
EDITOR = "EDITOR"
VIEWER = "VIEWER"
Input types
For mutation arguments, GraphQL distinguishes object types from inputs:
input CreateUserInput {
name: String!
email: String!
role: Role = VIEWER # default value
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
Input types can only contain scalars, enums, and other inputs. No nested object-type fields, no fields with arguments. This prevents inputs from accidentally being resolvable.
Interfaces
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
type Post implements Node {
id: ID!
title: String!
}
type Query {
node(id: ID!): Node
}
Interfaces let you have a common interface implemented by multiple types. The Node interface is the Relay convention for “anything with an ID.”
Clients can query the interface and ask for type-specific fields:
query {
node(id: "user-42") {
id
... on User { name }
... on Post { title }
}
}
... on TypeName is an “inline fragment” — narrows to type-specific fields.
Unions
union SearchResult = User | Post | Comment
type Query {
search(query: String!): [SearchResult!]!
}
Unions are like interfaces but without shared fields — types just appear in the same list.
query {
search(query: "alice") {
__typename
... on User { name email }
... on Post { title }
... on Comment { body }
}
}
__typename is a built-in meta-field returning the concrete type — essential for clients to know which case they got.
Arguments and default values
type Query {
users(limit: Int = 20, offset: Int = 0, role: Role): [User!]!
}
Arguments can have defaults. Required args (Int!) without defaults must be provided.
Directives
Annotations on schema elements that modify behavior. Built-in:
type User {
id: ID!
oldField: String @deprecated(reason: "Use newField")
newField: String
}
Query-side:
query GetUser($includeEmail: Boolean!) {
user(id: 42) {
name
email @include(if: $includeEmail)
oldName @deprecated
}
}
Built-in directives: @include, @skip, @deprecated, @specifiedBy. Custom directives let you add auth, complexity hints, etc.:
type Query {
adminPanel: AdminInfo! @auth(role: ADMIN)
}
The server-side library handles execution; the directive declaration is in the schema.
Operation types (root types)
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
type Subscription {
userCreated: User!
}
Query, Mutation, Subscription are the entry points. The schema definition is technically optional if you use these standard names.
Schema-first vs code-first
Two ways to build a schema:
| Approach | How |
|---|---|
| Schema-first | write .graphql SDL files; generate code from them |
| Code-first | define types in Python (Strawberry, Graphene); SDL is generated |
| Pros | Cons | |
|---|---|---|
| Schema-first | language-agnostic schema, single source of truth | manual binding to types, types defined twice |
| Code-first | type checker sees types, no double definition | SDL is generated (review noise) |
Modern Python: code-first with Strawberry (uses dataclass-like syntax + typing). For polyglot teams sharing one schema: schema-first.
# Code-first (Strawberry)
@strawberry.type
class User:
id: strawberry.ID
name: str
posts: list["Post"]
# Schema-first (.graphql file)
type User {
id: ID!
name: String!
posts: [Post!]!
}
# + Python code that binds resolvers to these types
Introspection
GraphQL has built-in introspection — clients can query the schema itself:
query {
__schema {
types {
name
fields { name type { name } }
}
}
}
Powers tools like GraphiQL (the in-browser query editor) and Apollo’s codegen. Built-in features include __type(name: "User") and __typename.
Disable introspection in production for “private” APIs to make schema discovery harder. It’s not real security (the schema is in your client code), but reduces casual reconnaissance.
# Strawberry: disable in production
schema = strawberry.Schema(query=Query, config=StrawberryConfig(disable_introspection=True))
Schema evolution
Additive changes are safe:
- Add a new field.
- Add a new type.
- Add a new enum value (careful: clients may exhaustively switch).
- Add an optional argument.
Breaking changes:
- Remove a field.
- Change a field’s type (
String→Int). - Make an existing optional argument required.
- Make a non-null field nullable (clients that depended on non-null break).
For deprecations:
type User {
fullName: String! @deprecated(reason: "Use firstName and lastName")
firstName: String!
lastName: String!
}
Tools like Apollo Studio track which fields are used; you can remove deprecated fields safely once usage drops to zero. See 06_errors_versioning.md.
Common pitfalls
- Mutable defaults in input types — defaults in SDL are evaluated by the library; treat as the spec describes (literal values).
[T]vs[T!]— non-null modifier on list element vs list itself. Get them wrong, clients see unexpected nulls.- One giant query type —
Queryends up with 100 fields. Use namespacing:Query.user(...),Query.admin: AdminQueries!withAdminQueries.users: [User!]!. - Introspection enabled in production for a private API — clients can pull the full schema (not “security” but information leak).
- Custom scalars without serialization tests —
DateTimerendering differently across timezones is a common bug.
Common interview confusions
- “Interfaces and unions are the same.” — interfaces have shared fields all implementers must have. Unions just group unrelated types.
- “
Int!is the same asInt.” —!means non-null.Int!cannot be null;Intcan be. - “You declare resolvers in the schema.” — schema is types only. Resolvers are separate code that bind to fields.
Interview angle
- “What’s the GraphQL schema?” — typed contract written in SDL: object types, fields, scalars, enums, inputs, queries, mutations, subscriptions. The source of truth that defines what clients can request.
- “Difference between object types and input types?” — object types are responses (can have fields with arguments and resolvers). Input types are mutation arguments (only scalars/enums/other inputs; no resolvable fields).
- “What’s
!in the schema?” — non-null modifier.String!means the field cannot be null.[String!]!means non-null list of non-null strings. - “Schema-first vs code-first?” — schema-first writes
.graphqlSDL and binds code to it (good for polyglot teams). Code-first defines types in Python/TS code and generates SDL (better for single-language teams with strong type checkers). - “What’s introspection and should it be enabled in production?” — built-in query (
__schema) returning the schema itself. Enable in dev for tooling; disable for “private” production APIs to reduce casual schema discovery (it’s not real security). - “How do you handle breaking changes in GraphQL?” — mark fields
@deprecated, monitor usage, remove when traffic drops to zero. Non-breaking additions (new fields/types/optional args) are free. There’s no version bump in GraphQL; the schema evolves additively.