Hasura · PostgreSQL · GraphQL

Can a Hasura custom function take an array of a table's row type?

Yes — with one caveat. Strongly typed in SQL, stringly typed at the edge.

TL;DR. Hasura will track a custom Postgres function whose argument is an array of a table's row type (e.g. mountains[]), and the Postgres side is fully strongly typed. But at the GraphQL boundary, Hasura exposes that argument as a custom scalar named after the Postgres array type (here, _mountains — Postgres's internal name for mountains[]). The client passes the value as a Postgres array literal of composite literals — a single opaque string — not as a structured GraphQL input object.

Strongly typed in SQL

Inside the function, mountains_input is a real mountains[]. (m).name, (m).elevation_m, etc. are real columns with real types. Wrong column count or wrong types → Postgres rejects it.

Stringly typed at the edge

At the GraphQL layer it's a single scalar with no per-field validation. Clients must hand-build a Postgres array literal of composite literals, with all the nested-quoting that implies.

Standing feature request to generate proper GraphQL input objects from composite types: hasura/graphql-engine#7078 (related: #3757).

The shape of it

Three pieces collaborate. A table whose row type we want to pass in. A function that takes an array of that row type. Hasura, tracking both and exposing the function at the GraphQL boundary.

1. Table
mountains(id, name, elevation_m, first_summited) — tracked by Hasura.
2. Function
summarize_mountains(mountains_input mountains[]) RETURNS SETOF mountains
3. GraphQL
$m: _mountains! — custom scalar, opaque to the client.

The table

SQL
CREATE TABLE mountains (
  id              serial PRIMARY KEY,
  name            text    NOT NULL,
  elevation_m     int     NOT NULL,
  first_summited  int     NOT NULL
);

INSERT INTO mountains (name, elevation_m, first_summited) VALUES
  ('K2',         8611, 1954),
  ('Denali',     6190, 1913),
  ('Mont Blanc', 4809, 1786);

The function

SQL
-- Argument is a strongly-typed array of the `mountains` row type.
-- Inside the body, (m).name / (m).elevation_m / etc are real columns
-- with real Postgres types.
CREATE FUNCTION summarize_mountains(mountains_input mountains[])
RETURNS SETOF mountains
LANGUAGE sql STABLE AS $$
  SELECT (m).*
  FROM unnest(mountains_input) AS m
  WHERE (m).elevation_m > 6000;
$$;

Nothing special goes in Hasura's metadata for the composite-array argument — Hasura figures it out from the Postgres signature alone.

The GraphQL query

GraphQL
query TallMountains($m: _mountains!) {
  summarize_mountains(args: { mountains_input: $m }) {
    id
    name
    elevation_m
    first_summited
  }
}

And the variables payload — this is where the catch lives:

JSON variables
{
  "m": "{\"(1,K2,8611,1954)\",\"(2,Denali,6190,1913)\",\"(3,\\\"Mont Blanc\\\",4809,1786)\"}"
}

The function receives a real mountains[] and the WHERE (m).elevation_m > 6000 clause does its job — Mont Blanc (4,809 m) is correctly filtered out:

Response
{
  "data": {
    "summarize_mountains": [
      { "id": 1, "name": "K2",     "elevation_m": 8611, "first_summited": 1954 },
      { "id": 2, "name": "Denali", "elevation_m": 6190, "first_summited": 1913 }
    ]
  }
}

Decoding the composite-array literal

That JSON string value is several layers of quoting compressed into one line. Peeling it back:

{ … }
Outer braces — Postgres array literal.
"(1,K2,8611,1954)"
Each element is a composite (row) literal: (col1,col2,col3,col4). Composite literals inside an array literal are double-quoted.
"(3,\"Mont Blanc\",…)"
Text values containing spaces or commas need another layer of double-quoting inside the composite literal.
"…\\\"Mont Blanc\\\"…"
All those double quotes then need backslash-escaping when the whole thing is embedded in a JSON string for the HTTP request.
(1, …, …, 1954)
Column order must match the table's column order. You can't name columns at this layer — that's the missing strong typing.

The pragmatic alternative: jsonb

If you don't strictly need Postgres-side type enforcement, take a jsonb argument and jsonb_to_recordset(…) inside the function. Clients then pass a normal JSON array of objects — no Postgres-literal escaping, no positional-column trap. You lose Postgres-side type checking, but for most GraphQL use cases that's the pragmatic call.

SQL
CREATE FUNCTION summarize_mountains_json(mountains_input jsonb)
RETURNS SETOF mountains
LANGUAGE sql STABLE AS $$
  SELECT m.*
  FROM jsonb_to_recordset(mountains_input)
    AS m(id int, name text, elevation_m int, first_summited int)
  WHERE m.elevation_m > 6000;
$$;

And the variables become a structured JSON array — no escape gymnastics:

JSON variables
{
  "m": [
    { "id": 1, "name": "K2",         "elevation_m": 8611, "first_summited": 1954 },
    { "id": 2, "name": "Denali",     "elevation_m": 6190, "first_summited": 1913 },
    { "id": 3, "name": "Mont Blanc", "elevation_m": 4809, "first_summited": 1786 }
  ]
}

When to reach for which

mountains[] when

  • The function is also called from elsewhere in SQL (triggers, other functions, psql) and you want Postgres to enforce the shape there too.
  • You want Postgres to do constraint-style validation (NOT NULL, types) before your function body sees the rows.

jsonb when

  • The function is GraphQL-facing only and clients shouldn't have to hand-build Postgres array literals.
  • You're willing to hand-roll input validation (or live without it) in exchange for clean JSON-object input.

Run the demo yourself

The full setup — Postgres + Hasura via Docker Compose, migrations, metadata, and the example request — lives in the companion repo. Two services come up on remapped ports (58080 for Hasura, 55432 for Postgres) so nothing collides with whatever you already have running.

bash
git clone https://github.com/mrxinu/hasura-composite-array-args.git
cd hasura-composite-array-args
docker compose up -d
# wait ~15s for migrations to apply, then:
curl -s -X POST http://localhost:58080/v1/graphql \
  -H 'Content-Type: application/json' \
  -d @example-request.json | python3 -m json.tool

The console is at http://localhost:58080/console; the GraphiQL tab is the easiest place to poke at the schema and watch Hasura's type for mountains_input show up as _mountains!.