Skip to content

GraphQL Directives

GraphQL directives in django-graphex allow you to transform field values at query execution time. They provide a powerful way to format, manipulate, and transform data without modifying your underlying models or resolvers.

Middleware requirement (required for ALL directives)

Middleware is required

Every directive — built-in and custom — requires GraphQLDirectiveMiddleware to be registered in your settings. Without it, directives parse and validate in the schema but are silently ignored at execution time (no error, no transform).

Add it to your settings before using any directive:

DJANGO_GRAPHEX = {
    "SCHEMA": "myapp.schema.schema",
    "MIDDLEWARE": ["django_graphex.middleware.GraphQLDirectiveMiddleware"],
}

And pass all_directives (or your combined directive list) to the schema:

from django_graphex.directives import all_directives
from django_graphex.schema import DjangoGraphQLSchema

schema = DjangoGraphQLSchema(
    query=Query,
    mutation=Mutation,
    directives=all_directives,
)

The middleware coerces directive arguments and dispatches transforms to the registered directive class. Both the schema directives= list and the middleware entry are required — either alone does nothing.

Limits on directive output and cost

Directives can lengthen text (@replace, @center), and a client can repeat them across aliases and list rows. Three settings bound that:

  • MAX_REPLACE_OUTPUT_LENGTH: cap on each @replace result.
  • MAX_DIRECTIVE_OUTPUT_TOTAL (default 10,000,000 characters): budget for the text all directives add to one HTTP request. Every item of a batch draws from the same budget, and each subscription event has its own. Past it, further directive uses return a field error and produce no text. Applies to custom directives you register as well, because the middleware charges any directive whose result is longer than its input.
  • COST_DIRECTIVE (default 0): cost added per directive use so they count toward MAX_QUERY_COST.

See Settings and Query limits.

Asynchronous resolvers

When using graphql-core's native asynchronous execution, the middleware waits for an asynchronous resolver's value before applying field directives in query order. For example, an asynchronous resolver returning "Hello" yields "HELLO" for @uppercase, not a representation of its coroutine.

The built-in Django HTTP views remain synchronous. They reject asynchronous resolvers with a JSON error and HTTP 500; adding a directive does not enable asynchronous HTTP execution. Use synchronous resolvers and middleware with these views. A rejected request does not run or cancel a caller-owned pending Future or Task, even when another middleware wraps the directive result.

Overview

Directives are applied to fields in your GraphQL queries and are processed after the field value is resolved. They enable you to:

  • Format strings: Transform text case, encoding, and structure
  • Format dates: Display dates in various formats and relative time
  • Format numbers: Apply number formatting and currency display
  • Manipulate lists: Transform and sample list data

Usage

Directives are applied in GraphQL queries using the @directive_name syntax:

query {
  user {
    name @uppercase
    email @lowercase
    joinDate @date(format: "MMMM DD, YYYY")
    balance @currency(symbol: "€")
  }
}

Standard GraphQL directives (spec built-ins)

all_directives holds 30 directive instances: the library's own 25 plus the GraphQL specification's 5 — @skip, @include, @deprecated, @specifiedBy and @oneOf (graphql-core's specified_directives bundle). The last five are not django-graphex directives — @skip / @include are evaluated by the graphql-core executor itself (they work without GraphQLDirectiveMiddleware), while @deprecated, @specifiedBy and @oneOf are type-system (SDL) directives that annotate the schema rather than transforming values. Only the three documented below are ones you interact with: @specifiedBy (custom-scalar specification URLs) and @oneOf (exactly-one-key input objects) ride along with graphql-core's bundle and are never emitted by this library's compiler. See the API reference.

Conditionally exclude parts of a query. The condition is almost always a variable, so the client toggles sections of one static query per request. Both are honored by the query optimizer and by the cost / depth rules:

query Posts($withComments: Boolean!, $skipAuthor: Boolean!) {
  posts {
    results {
      title
      comments @include(if: $withComments) {
        results { body }
      }
      author @skip(if: $skipAuthor) {
        name
      }
    }
  }
}
variables
{ "withComments": true, "skipAuthor": false }

Marks a field, argument, input field, or enum value as deprecated in the schema. It is not written in queries — clients discover it through introspection, and GraphiQL-style IDEs strike the field through. You don't hand-write @deprecated in SDL: pass deprecation_reason= in Python and the compiler emits it for you.

A descriptor field:

from django_graphex.core import ObjectType, CharField

class UserType(ObjectType):
    username = CharField()
    email = CharField(deprecation_reason="Use contactEmail instead.")

A Django-mounted field (e.g. DjangoObjectField):

from django_graphex.core import ObjectType
from django_graphex.fields import DjangoObjectField

class Query(ObjectType):
    user = DjangoObjectField(UserType, deprecation_reason="Use `viewer` instead.")

A mutation builder (CreateField / UpdateField / DeleteField / MutationFields):

from django_graphex.core import ObjectType
from django_graphex.mutation import DjangoModelMutation
from django.contrib.auth.models import User

class UserMutation(DjangoModelMutation):
    class Meta:
        model = User

class Mutation(ObjectType):
    user_create = UserMutation.CreateField(
        deprecation_reason="Use userCreateV2 instead."
    )

All three compile to the same SDL shape:

type UserType {
  username: String
  email: String @deprecated(reason: "Use contactEmail instead.")
}

type Query {
  user(id: ID!): UserType @deprecated(reason: "Use `viewer` instead.")
}

type Mutation {
  userCreate(newUser: UserCreateGenericType!): UserMutation
    @deprecated(reason: "Use userCreateV2 instead.")
}

Introspection hides deprecated fields by default

A deprecated field is omitted from __type { fields { name } } unless the client asks for it explicitly with fields(includeDeprecated: true). This is graphql-core's standard introspection behavior, not something django-graphex adds — deprecation doesn't remove the field from the schema, it just hides it from the default introspection listing.

The rest of this page covers the library's own value-transform directives, which all require the middleware.

String Directives

String directives provide various text transformation capabilities:

Case Conversion

Transform text case with these directives:

query GetUser {
  user {
    name @uppercase        # "JOHN DOE"
    email @lowercase       # "john.doe@example.com"
    bio @capitalize        # "Hello world" → "Hello world"
    title @title_case       # "hello world" → "Hello World"
    status @swap_case       # "Hello" → "hELLO"
  }
}

All three directives recognize words separated by spaces, underscores or hyphens, plus camelCase and PascalCase boundaries. Acronym blocks remain words: HTTPServer becomes httpServer, http_server or http-server. The conversion changes field values, not GraphQL schema names. Other punctuation and characters keep their previous handling.

query GetData {
  post {
    apiName @camel_case      # "order_total" → "orderTotal"
    title @snake_case        # "orderTotal" → "order_total"
    url @kebab_case          # "order_total" → "order-total"
  }
}

String Manipulation

query GetContent {
  post {
    content @strip                    # Remove whitespace
    title @strip(chars: ".")          # Remove specific characters
    code @center(width: 20)           # Center text in 20 characters
    header @center(width: 30, fillchar: "-")  # Center with dashes
  }
}
query GetPost {
  post {
    content @replace(old: "GraphQL", new: "GQL")
    text @replace(old: " ", new: "_", count: 3)  # Replace first 3 spaces
  }
}
query GetPost {
  post {
    # Cut on a word boundary, append "…" (default)
    summary @truncate(length: 80)              # "A long summary…"
    # Cut mid-word with a custom suffix
    teaser @truncate(length: 10, killwords: true, end: "...")
    # URL-safe slug (Django slugify)
    title @slugify                             # "My Post!" → "my-post"
  }
}
Directive Args Behavior
@truncate length: Int!, end: String = "…", killwords: Boolean = false Shorten to length, appending end; break on a word boundary unless killwords.
@slugify — Convert to a URL-safe slug.

Default Values

Provide fallback values for empty or null fields:

query GetUser {
  user {
    firstName @default(to: "Anonymous")
    lastName @default(to: "User")
    bio @default(to: "No bio available")
  }
}
{
  "data": {
    "user": {
      "firstName": "John",        // Original value
      "lastName": "Anonymous",    // Default applied (was null)
      "bio": "No bio available"  // Default applied (was empty)
    }
  }
}

Encoding Directives

Handle base64 encoding and decoding. Supports any Unicode input — non-ASCII characters (accented letters, emoji, etc.) are encoded as UTF-8 before base64 encoding. The implementation uses URL-safe base64 (- and _ instead of + and /), so strings encoded with standard base64 containing + or / will fail to decode:

query GetData {
  apiKey @base64(op: "encode")      # Encode to base64
  token @base64(op: "decode")       # Decode from base64
}
// Input: "hello world"
// With @base64(op: "encode"): "aGVsbG8gd29ybGQ="
// With @base64(op: "decode"): Original string from base64
// Non-ASCII input (e.g. "Ñoño") is encoded as UTF-8 — no crash.

Number Directives

Format numeric values with precision and style:

@number and @currency only format String fields

Both produce a formatted string, which a field typed Int or Float cannot serialize. The Decimal and BigInt scalars reject it as well. On those fields the raw value is returned unchanged and the formatting is skipped, so the response stays valid instead of nulling the field with an opaque coercion error. Expose the value as a String field when you want the formatting to apply. The format-spec width/precision cap still applies on every field type.

A resolver's None stays GraphQL null under either directive. On a non-null field, GraphQL reports the usual nullability error and bubbles to the nearest nullable parent. The @number cap still applies to null values, and an invalid format spec still errors on a field where formatting applies.

Basic Number Formatting

The as argument accepts any Python numeric format spec. Format specs with a width or precision exceeding 100 are rejected with a field error to prevent memory exhaustion from client-supplied oversized specs (e.g. "1000000.5f").

query GetStats {
  product {
    price @number(as: ".2f")        # "123.45"
    weight @number(as: ".3f")       # "12.500"
    rating @number(as: ".1f")       # "4.2"
  }
}

Currency Formatting

Format numbers as currency with customizable symbols:

query GetPrices {
  product {
    priceUSD @currency                    # "$123.45" (default)
    priceEUR @currency(symbol: "€")       # "€123.45"
    priceGBP @currency(symbol: "£")       # "£123.45"
    priceJPY @currency(symbol: "¥")       # "¥123.45"
  }
}
{
  "data": {
    "product": {
      "priceUSD": "$1,234.56",
      "priceEUR": "€1,234.56",
      "priceGBP": "£1,234.56",
      "priceJPY": "¥1,234.56"
    }
  }
}

Date Directives

Powerful date and time formatting with multiple options:

Standard Date Formats

query GetPost {
  post {
    createdAt @date(format: "YYYY-MM-DD")         # "2023-12-01"
    updatedAt @date(format: "MMMM DD, YYYY")      # "December 01, 2023"
    publishedAt @date(format: "DD/MM/YYYY HH:mm") # "01/12/2023 14:30"
    timestamp @date(format: "iso")                # "2023-12-01T14:30:00"
    jsDate @date(format: "javascript")            # "Fri Dec 01 2023 14:30:00"
  }
}

Naive dates and timezone

For text input, missing time components now default to zero. A resolver value of "2026-01-10" or "January 10, 2026" formats as 2026-01-10T00:00:00 with @date(format: "iso"); "2026-01-10 14:30" formats as 2026-01-10T14:30:00. The zero default includes microseconds, although the directive's iso output does not display them. A time-only string such as "14:30" still uses the current date for missing date components, but formats with seconds zero. Explicit time and offset components are preserved as inputs. Absolute output formats display an originally aware Python datetime, or text carrying an explicit numeric offset or Z, in Django's active timezone. The instant is unchanged, but the displayed calendar date and clock can change. For example, with the active timezone set to America/New_York:

{ publishedAt @date(format: "iso") }

If the resolver returns "2026-01-01T03:30:00+02:00", the field returns "2025-12-31T20:30:00". The iso output still omits the offset and microseconds; clients must not read it as an offset-bearing timestamp. The same conversion applies to default, js/javascript, and custom absolute formats. Clock-adjacent +00:00 and +0000 also count as explicit offsets: "2026-01-10T00:30:00+00:00" displays as "2026-01-09T19:30:00" in America/New_York, just like the equivalent Z text. Text with no timezone information follows a different rule: its written date and time are interpreted directly in the active Django timezone, even when USE_TZ=False. No assumed UTC instant is converted. For example, with TIME_ZONE="UTC" and Europe/Madrid active in July, a resolver returning "2026-07-10 14:30" gives "14:30 +0200" for @date(format: "HH:mm ZZ"), rather than "14:30 +0000". Its iso output still shows "2026-07-10T14:30:00" without an offset.

The timezone-less text rule also changes "time ago" and "time ago 2d": they compare the wall clock in the active zone, so relative wording can change. Text carrying an explicit numeric offset or Z keeps its previous relative behavior. Named UTC/GMT text and unknown timezone names are not treated as timezone-less; their existing fallback and warning behavior remains. Python date, naive datetime, and other input kinds keep their existing behavior. Missing time components still become zero, and a time-only string still gets its missing date from the parser's current-date default. Near midnight, that date can differ from the active zone's calendar date; this change does not reselect it or add a daylight-saving gap/overlap policy.

When a nullable field resolves to null, @date leaves it as null with or without an explicit format. It does not replace missing dates with the INVALID FORMAT STRING fallback. For a field declared non-null in the GraphQL schema, the same null value produces the normal GraphQL error and null propagation; a root non-null field can make the entire response data null. Check the field's declared nullability rather than treating the directive as a default-value provider.

@date converts a date or naive datetime into an aware datetime in the active Django timezone (falling back to TIME_ZONE) before formatting, even when USE_TZ=False. For example, a resolver returning date(2026, 1, 10) is interpreted as 2026-01-10 00:00 in that timezone; the directive does not infer a timezone from the client or preserve a naive midnight. Supply an offset-aware datetime when the instant must be explicit.

This is the directive's formatting behavior, not native DateTime scalar input coercion: the native DateTime scalar preserves naive input and leaves the model write path to Django's own timezone rules.

Relative Time Formatting

query GetActivity {
  post {
    createdAt @date(format: "time ago")       # "2 hours ago" / "in 3 days"
    updatedAt @date(format: "time ago 2d")    # Shows "Yesterday", "Tomorrow", or date
  }
}
{
  "data": {
    "post": {
      "createdAt": "2 hours ago",
      "updatedAt": "Yesterday"    // or "Dec 01, 2023" if more than 2 days
    }
  }
}

Custom Date Patterns

Build custom date formats using these tokens:

Token Description Example
YYYY 4-digit year 2023
YY 2-digit year 23
MMMM Full month name December
MMM Short month name Dec
MM Month number (padded) 12
DD Day of month (padded) 01
dddd Full day name Friday
ddd Short day name Fri
HH Hour (24h, padded) 14
hh Hour (12h, padded) 02
mm Minutes (padded) 30
ss Seconds (padded) 45
A AM/PM PM
query GetEvents {
  event {
    startDate @date(format: "dddd, MMMM DD, YYYY")    # "Friday, December 01, 2023"
    endDate @date(format: "DD-MM-YY HH:mm A")         # "01-12-23 02:30 PM"
    created @date(format: "YYYY/MM/DD")               # "2023/12/01"
  }
}

List Directives

Transform and manipulate list data:

Shuffle Directive

Randomly reorder list elements:

query GetRandomPosts {
  posts {
    tags @shuffle {
      name
      color
    }
  }
}

Sample Directive

Get a random sample from a list:

query GetSampleTags {
  post {
    tags @sample(k: 3) {  # Get 3 random tags
      name
      color
    }
  }
}

Unique Directive

De-duplicate a list while preserving order:

query GetTags {
  post {
    tags @unique  # ["a", "b", "a", "c"] → ["a", "b", "c"]
  }
}

Math Directives

Perform mathematical operations on numbers:

Floor, Ceil, Round and Abs

query GetStats {
  product {
    rating @floor              # 4.7 → 4
    price @ceil                # 99.1 → 100
    score @round(precision: 1) # 4.27 → 4.3
    score @round               # 4.27 → 4
    delta @abs                 # -3.5 → 3.5
  }
}

String / Float fields and null

@floor, @ceil, @round and @abs work on both Float and String fields — when the field is a String the result comes back as a string. A null value passes through unchanged.

@floor rejects NaN, positive infinity, and negative infinity after numeric coercion with GraphQLError('@floor cannot operate on a non-finite value.'). This also applies to numeric strings such as "nan" and "inf". Finite values, including negative zero, retain their existing rounding behavior. This rule applies only to @floor, not to the other math directives.

Decimal and BigInt fields

On DecimalField (Decimal scalar) and BigIntegerField (BigInt scalar) the math directives compute on the exact value, never on a float. @floor and @ceil return a whole number, @abs keeps the scale, and @round rounds half to even on the exact decimal digits (so 2.665 with precision: 2 is 2.66), quantizing to exactly the requested precision and returning a whole number for a precision of zero or below. A precision beyond the stored scale leaves the value unchanged, and so does a value with a huge exponent (1E+100000), which is already whole. Results come back in the scalar's own string format. @number and @currency cannot produce text these scalars accept, so, as on Int and Float fields, they return the raw value unchanged; use a String field to format.

Combining Directives

Chain multiple directives for complex transformations:

query GetFormattedData {
  user {
    firstName @default(to: "Anonymous") @title_case @strip
    email @lowercase @strip
    bio @default(to: "No bio") @capitalize @replace(old: ".", new: "!")
  }
  post {
    title @title_case @replace(old: "GraphQL", new: "GQL")
    viewCount @number(as: ",.0f")
    createdAt @date(format: "MMMM DD, YYYY")
  }
}
{
  "data": {
    "user": {
      "firstName": "Anonymous",
      "email": "user@example.com",
      "bio": "Welcome to my profile!"
    },
    "post": {
      "title": "Getting Started With GQL",
      "viewCount": "1,245",
      "createdAt": "December 01, 2023"
    }
  }
}

Custom Directives

While django-graphex provides many built-in directives, you can create your own. A custom directive is a class that transforms the resolved value of a field. The full recipe is four steps, shown end-to-end below.

The example here is verified by the test suite (tests/test_directives.py::CustomDirectiveTest), so it stays in sync with the code.

1. Define the directive

Subclass BaseExtraGraphQLDirective, declare any arguments in get_args(), and transform the value in resolve(). The directive name is derived from the class name (the GraphQLDirective suffix is stripped and the rest is snake_cased), so MaskGraphQLDirective becomes @mask (and, e.g., CreditCardGraphQLDirective would become @credit_card).

# myapp/directives.py
from django_graphex.directives.base import BaseExtraGraphQLDirective
from graphql import GraphQLArgument, GraphQLNonNull, GraphQLInt, GraphQLString


class MaskGraphQLDirective(BaseExtraGraphQLDirective):
    """Keep the last `visible` characters, masking the rest."""

    @staticmethod
    def get_args():
        return {
            "visible": GraphQLArgument(
                GraphQLNonNull(GraphQLInt),
                description="Number of trailing characters to leave visible",
            ),
            "char": GraphQLArgument(
                GraphQLString, description="Masking character (default: '*')"
            ),
        }

    @staticmethod
    def resolve(value, args, directive, root, info, **kwargs):
        if not value:
            return value
        text = str(value)
        visible = args.get("visible") or 0
        char = args.get("char") or "*"
        if visible >= len(text):
            return text
        return char * (len(text) - visible) + text[len(text) - visible:]

The resolve(value, args, directive, root, info, **kwargs) signature receives the already-resolved field value and the coerced args dict — read them with args.get("name"), no AST parsing required.

2. Register it on the schema

Pass an instance alongside all_directives (which already bundles the library's 25 built-ins plus the 5 standard GraphQL directives: @skip, @include, @deprecated, @specifiedBy and @oneOf):

# myapp/schema.py
from django_graphex.directives import all_directives
from django_graphex.schema import DjangoGraphQLSchema
from myapp.directives import MaskGraphQLDirective

schema = DjangoGraphQLSchema(
    query=Query,
    directives=[*all_directives, MaskGraphQLDirective()],
)

3. Enable the middleware

Custom directives are applied by GraphQLDirectiveMiddleware. Without it the directive parses and validates but does nothing:

# settings.py
DJANGO_GRAPHEX = {
    "SCHEMA": "myapp.schema.schema",
    "MIDDLEWARE": ["django_graphex.middleware.GraphQLDirectiveMiddleware"],
}

4. Use it

query {
  card @mask(visible: 4)            # "4111111111111234" -> "************1234"
  card @mask(visible: 4, char: "#") #                   -> "############1234"
}

Because the middleware coerces arguments with get_directive_values, every directive argument may also be supplied as a GraphQL variable:

query Mask($n: Int!) {
  user { creditCard @mask(visible: $n) }
}

Transformation directive locations

Library transformation directives, including custom subclasses of BaseExtraGraphQLDirective, apply to fields only (FIELD). Placing one on a fragment spread (FRAGMENT_SPREAD) or inline fragment (INLINE_FRAGMENT) now produces a GraphQL validation error before any resolver runs. Put the transform on each field whose value should change; a field inside a named or inline fragment is still a field and works normally.

Before (now invalid):

query { ...Words @uppercase }
fragment Words on Query { text }

After (valid):

query { ...Words }
fragment Words on Query { text @uppercase }

This does not change the GraphQL specification's @skip and @include directives: they can still control field and fragment selection. A third-party raw GraphQLDirective retains the locations its author declares; this field-only rule is for library transformation directives, not a new fragment inheritance engine.

How it works (and two gotchas)

  • These are execution / value-transform directives. They run on FIELD locations and post-process the selected field's resolved value. They are not schema/SDL type-system directives and do not change resolution logic.
  • Both steps are required: the directive must be in the schema's directives= list and the middleware must be enabled. Instantiating the directive also self-registers it so the middleware can find it by name.

Schema Integration

Add directives to your GraphQL schema:

from django_graphex.directives import all_directives
from django_graphex.core import ObjectType
from django_graphex.schema import DjangoGraphQLSchema

class Query(ObjectType):
    # Your query fields here
    pass

schema = DjangoGraphQLSchema(
    query=Query,
    directives=all_directives  # Include all built-in directives
)
from django_graphex.directives import all_directives
from django_graphex.schema import DjangoGraphQLSchema
from .directives import MaskGraphQLDirective

# all_directives already includes the 5 spec GraphQL directives
# (@skip, @include, @deprecated, @specifiedBy, @oneOf), so just append
# your own.
custom_directives = [
    *all_directives,
    MaskGraphQLDirective()
]

schema = DjangoGraphQLSchema(
    query=Query,
    directives=custom_directives
)

Middleware Integration

Enable directive processing with middleware:

DJANGO_GRAPHEX = {
    'SCHEMA': 'myapp.schema.schema',
    'MIDDLEWARE': [
        'django_graphex.middleware.GraphQLDirectiveMiddleware',
    ],
}

Real-World Examples

Blog Post Formatting

query GetBlogPost {
  post(id: "1") {
    title @title_case
    content @strip
    excerpt @default(to: "No excerpt available") @capitalize
    author {
      name @title_case
      email @lowercase
      bio @default(to: "No bio") @strip
    }
    publishedAt @date(format: "MMMM DD, YYYY")
    updatedAt @date(format: "time ago")
    viewCount @number(as: ",.0f")
    tags @sample(k: 5) {
      name @uppercase
    }
  }
}

E-commerce Product Display

query GetProduct {
  product(id: "123") {
    name @title_case
    description @strip @default(to: "No description available")
    price @currency(symbol: "$")
    originalPrice @currency(symbol: "$")
    discount @number(as: ".0%")
    weight @number(as: ".2f")
    dimensions @replace(old: "x", new: " × ")
    createdAt @date(format: "YYYY-MM-DD")
    lastModified @date(format: "time ago")
    reviews @shuffle {
      rating @number(as: ".1f")
      comment @strip @default(to: "No comment")
      createdAt @date(format: "MMM DD, YYYY")
    }
  }
}

User Profile Display

query GetUserProfile {
  user {
    username @lowercase
    displayName @default(to: "Anonymous User") @title_case
    email @lowercase
    bio @default(to: "No bio available") @strip @capitalize
    location @title_case
    website @lowercase
    socialLinks {
      twitter @replace(old: "https://twitter.com/", new: "@")
      linkedin @lowercase
    }
    joinDate @date(format: "MMMM YYYY")
    lastActive @date(format: "time ago")
    postCount @number(as: ",.0f")
    followerCount @number(as: ",.0f")
  }
}

Performance Considerations

Performance Tips

  1. Directive Order: Directives are processed in order, so place expensive operations last
  2. Caching: Directive results aren't cached by default - consider caching formatted values
  3. Complex Formatting: For heavy date/time operations, consider pre-formatting in resolvers
  4. List Operations: Be cautious with shuffle/sample on very large lists

Error Handling

Directives handle errors gracefully:

query {
  post {
    invalidDate @date(format: "YYYY-MM-DD")     # Returns "INVALID FORMAT STRING"
    nullValue @currency                         # Returns null (nullable field)
    emptyString @default(to: "fallback")        # Returns "fallback"
    emptyList @default(to: "fallback")          # Returns [] — never replaced
  }
}

Input Validation (Bad Client Input)

The following directives raise a clean GraphQLError when given bad input, instead of surfacing a raw Python exception (which would reach the client as an unhandled 500 or an opaque errors[] entry):

Directive Bad input Error raised
@base64(op:"decode") Non-base64 or non-UTF-8 bytes GraphQLError('@base64 could not decode value: ...')
@currency Non-numeric field value GraphQLError('@currency could not format value ...: expected a numeric value.')
@floor, @ceil, @round, @abs Non-numeric field value GraphQLError('@floor/@ceil/@round/@abs could not convert value ... to a number.')
@floor Non-finite number or numeric string GraphQLError('@floor cannot operate on a non-finite value.')
@center fillchar length ≠ 1 (empty or multi-char) GraphQLError('@center fillchar must be exactly one character; got ...')

Null and empty values

@base64 returns None on an empty/null input. On a String field @number and @currency return null for a None value; falsy but coercible values such as 0, False, and "" still format as zero. @floor, @ceil, @round, and @abs return None when the field value is None, and so does every string directive (@uppercase, @slugify, @truncate, …) — a null column is never rendered as the literal text "None". These are NOT error conditions.

@default substitutes only null and the empty string

Other falsy values are legitimate data and pass through untouched: 0, 0.0, false, an empty list and an empty object keep their value.

Numeric string fields

@floor, @ceil, @round, and @abs work on String fields that contain a numeric value (e.g. "3.7"). A non-numeric string (e.g. "abc") raises GraphQLError rather than propagating ValueError.

Best Practices

Directive Best Practices

  1. Use Defaults: Always provide fallback values for nullable fields
  2. Format Consistently: Use the same date/number formats across your app
  3. Chain Wisely: Order directive chains logically (clean → transform → format)
  4. Test Edge Cases: Test with null, empty, and invalid values
  5. Document Usage: Document custom directive usage in your API documentation
  6. Consider Performance: Use directives for display formatting, not heavy processing

GraphQL directives in django-graphex provide a powerful, flexible way to format and transform your API responses, making your GraphQL API more user-friendly and consistent across different client applications.