Adding Mutations
This guide picks up where Your First API left off. You have a working blog API with queries. Now you add the write path: database functions that validate and persist data, and GraphQL mutations that call them.
How Mutations Work in FraiseQL
Section titled “How Mutations Work in FraiseQL”Each mutation follows the same path:
- GraphQL receives the mutation and validates the input type
- FraiseQL calls the mapped database function with the input values
- The DB function validates, inserts or updates, and returns the row’s
id - FraiseQL queries the view by that
idand returns the shaped response
Your schema file defines what the mutation accepts. The SQL function enforces invariants. The view resolves the response.
Step 1: Write the Database Functions
Section titled “Step 1: Write the Database Functions”Create mutation functions in db/schema/03_mutations/. Each function validates its inputs, performs the write, and returns the id so FraiseQL can resolve the response through the existing view.
CREATE OR REPLACE FUNCTION fn_create_user( p_username TEXT, p_email TEXT, p_bio TEXT DEFAULT NULL) RETURNS mutation_response LANGUAGE plpgsql AS $$DECLARE v_id UUID;BEGIN /*validate: no duplicate email*/ IF EXISTS (SELECT 1 FROM tb_user WHERE email = p_email) THEN RETURN ROW('failed:conflict', 'email already registered: ' || p_email, NULL, 'User', NULL, NULL, NULL, NULL)::mutation_response; END IF; INSERT INTO tb_user (identifier, username, email, bio) VALUES ('usr_' || lower(regexp_replace(p_username, '[^a-z0-9]', '_', 'gi')), p_username, p_email, p_bio) RETURNING id INTO v_id; PERFORM sync_tv_user(); RETURN ROW('success', 'User created', v_id, 'User', (SELECT data FROM v_user WHERE id = v_id), NULL, NULL, NULL)::mutation_response;END; $$;CREATE OR REPLACE FUNCTION fn_create_post( p_title TEXT, p_content TEXT, p_author_id UUID) RETURNS mutation_response LANGUAGE plpgsql AS $$DECLARE v_fk_user BIGINT; v_slug TEXT; v_id UUID;BEGIN /*validate: author exists*/ SELECT pk_user INTO v_fk_user FROM tb_user WHERE id = p_author_id; IF v_fk_user IS NULL THEN RETURN ROW('failed:not_found', 'author not found: ' || p_author_id, NULL, 'Post', NULL, NULL, NULL, NULL)::mutation_response; END IF; v_slug := lower(trim(both '-' FROM regexp_replace(p_title, '[^a-zA-Z0-9]+', '-', 'g'))); INSERT INTO tb_post (identifier, fk_user, title, slug, content) VALUES ('pst_' || v_slug, v_fk_user, p_title, v_slug, p_content) RETURNING id INTO v_id; PERFORM sync_tv_post(); RETURN ROW('success', 'Post created', v_id, 'Post', (SELECT data FROM v_post WHERE id = v_id), NULL, NULL, NULL)::mutation_response;END; $$;CREATE OR REPLACE FUNCTION fn_publish_post(p_post_id UUID)RETURNS mutation_response LANGUAGE plpgsql AS $$DECLARE v_id UUID;BEGIN UPDATE tb_post SET is_published = true, updated_at = NOW() WHERE id = p_post_id RETURNING id INTO v_id; IF v_id IS NULL THEN RETURN ROW('failed:not_found', 'post not found: ' || p_post_id, NULL, 'Post', NULL, NULL, NULL, NULL)::mutation_response; END IF; PERFORM sync_tv_post(); RETURN ROW('success', 'Post published', v_id, 'Post', (SELECT data FROM v_post WHERE id = v_id), NULL, NULL, NULL)::mutation_response;END; $$;MySQL mutations use stored procedures with a JSON output convention instead of PostgreSQL composite types. Define your mutation function as a stored procedure that returns a single JSON column:
CREATE PROCEDURE fn_create_post( IN p_title VARCHAR(255), IN p_body TEXT)BEGIN DECLARE v_id CHAR(36) DEFAULT (UUID()); INSERT INTO post (id, title, body, created_at) VALUES (v_id, p_title, p_body, NOW()); SELECT JSON_OBJECT( 'id', v_id, 'title', p_title, 'body', p_body ) AS result;END;The Python mutation declaration is the same as PostgreSQL:
@fraiseql.mutation(sql_source="fn_create_post", operation="CUSTOM")def create_post(title: str, body: str) -> Post: passSQLite has no stored procedures, so no function files are needed. Use operation="CREATE" or operation="UPDATE" in Step 2 and FraiseQL generates the INSERT or UPDATE SQL automatically from the input type fields. The target table is the return type lowercased — User → user. Pass sql_source="your_table" to override this if your table name differs.
CREATE OR ALTER PROCEDURE dbo.fn_create_user @p_username NVARCHAR(255), @p_email NVARCHAR(255), @p_bio NVARCHAR(MAX) = NULLAS BEGIN SET NOCOUNT ON; /*validate: no duplicate email*/ IF EXISTS (SELECT 1 FROM dbo.tb_user WHERE email = @p_email) THROW 50001, 'email already registered', 1; DECLARE @v_id UNIQUEIDENTIFIER = NEWSEQUENTIALID(); INSERT INTO dbo.tb_user (id, identifier, username, email, bio) VALUES (@v_id, 'usr_' + LOWER(REPLACE(@p_username, ' ', '_')), @p_username, @p_email, @p_bio); SELECT CAST(@v_id AS NVARCHAR(36)) AS id;END;CREATE OR ALTER PROCEDURE dbo.fn_create_post @p_title NVARCHAR(MAX), @p_content NVARCHAR(MAX), @p_author_id UNIQUEIDENTIFIERAS BEGIN SET NOCOUNT ON; DECLARE @v_fk_user BIGINT; SELECT @v_fk_user = pk_user FROM dbo.tb_user WHERE id = @p_author_id; /*validate: author exists*/ IF @v_fk_user IS NULL THROW 50002, 'author not found', 1; DECLARE @v_slug NVARCHAR(255) = LOWER(REPLACE(@p_title, ' ', '-')); DECLARE @v_id UNIQUEIDENTIFIER = NEWSEQUENTIALID(); INSERT INTO dbo.tb_post (id, identifier, fk_user, title, slug, content) VALUES (@v_id, 'pst_' + @v_slug, @v_fk_user, @p_title, @v_slug, @p_content); SELECT CAST(@v_id AS NVARCHAR(36)) AS id;END;CREATE OR ALTER PROCEDURE dbo.fn_publish_post @p_post_id UNIQUEIDENTIFIERAS BEGIN SET NOCOUNT ON; UPDATE dbo.tb_post SET is_published = 1, updated_at = SYSDATETIMEOFFSET() WHERE id = @p_post_id; IF @@ROWCOUNT = 0 THROW 50003, 'post not found', 1; SELECT CAST(@p_post_id AS NVARCHAR(36)) AS id;END;Step 2: Map to GraphQL
Section titled “Step 2: Map to GraphQL”Add input types and mutation definitions to your schema file. For "CUSTOM" mutations, sql_source names the stored procedure. For auto-generated SQL ("CREATE" / "UPDATE" / "DELETE"), sql_source sets the target table name — defaults to the return type lowercased if omitted. The return type always resolves through the existing view.
# Add to schema.py — User and Post types are already defined
@fraiseql.inputclass CreateUserInput: username: str email: Email bio: str | None = None
@fraiseql.inputclass CreatePostInput: title: str content: str author_id: ID
@fraiseql.inputclass PublishPostInput: post_id: ID
@fraiseql.mutation(sql_source="fn_create_user", operation="CUSTOM")def create_user(input: CreateUserInput) -> User: """Create a new user.""" pass
@fraiseql.mutation(sql_source="fn_create_post", operation="CUSTOM")def create_post(input: CreatePostInput) -> Post: """Create a new post.""" pass
@fraiseql.mutation(sql_source="fn_publish_post", operation="CUSTOM")def publish_post(input: PublishPostInput) -> Post: """Publish a post.""" pass// Add to schema.ts — User and Post types are already definedimport { Mutation, Input } from 'fraiseql';
@Input()class CreateUserInput { username: string; email: string; bio?: string;}
@Input()class CreatePostInput { title: string; content: string; authorId: string;}
@Input()class PublishPostInput { postId: string;}
@Mutation()function createUser(input: CreateUserInput): User { /** Create a new user. */}
@Mutation()function createPost(input: CreatePostInput): Post { /** Create a new post. */}
@Mutation()function publishPost(input: PublishPostInput): Post { /** Publish a post. */}// Add to schema.go — User and Post types are already defined
func init() { fraiseql.NewMutation("createUser"). ReturnType("User"). Config(map[string]interface{}{ "sql_source": "fn_create_user", "operation": "CUSTOM", }). Arg("username", "String", nil). Arg("email", "String", nil). Arg("bio", "String", nil, true). Register()
fraiseql.NewMutation("createPost"). ReturnType("Post"). Config(map[string]interface{}{ "sql_source": "fn_create_post", "operation": "CUSTOM", }). Arg("title", "String", nil). Arg("content", "String", nil). Arg("authorId", "ID", nil). Register()
fraiseql.NewMutation("publishPost"). ReturnType("Post"). Config(map[string]interface{}{ "sql_source": "fn_publish_post", "operation": "CUSTOM", }). Arg("postId", "ID", nil). Register()}Step 3: Handle updated_at
Section titled “Step 3: Handle updated_at”How updated_at is managed depends on which mutation style you use.
For stored-procedure ("custom") mutations, the function body controls the timestamp. Set it explicitly in any mutation that modifies a row:
| Database | Expression |
|---|---|
| PostgreSQL | updated_at = NOW() |
| MySQL | Handled automatically via ON UPDATE CURRENT_TIMESTAMP — no action needed |
| SQL Server | updated_at = SYSDATETIMEOFFSET() |
For auto-generated SQL ("create" / "update") mutations, FraiseQL never writes updated_at automatically regardless of database. Your options are:
- Add a database trigger on the table that sets
updated_aton every write (recommended) - Include
updated_atas a field in the input type and pass the value from the client
Step 4: Build and Test
Section titled “Step 4: Build and Test”Rebuild to pick up the new function files:
confiture build --env local✓ Schema built successfully in 0.5s → Created 3 tables, 3 views, 3 functionsRecompile and serve:
fraiseql compile && fraiseql runOpen the GraphQL Playground at http://localhost:8080/graphql.
createUser
Section titled “createUser”mutation { createUser(input: { username: "alice", email: "alice@example.com" }) { id username email }}{ "data": { "createUser": { "id": "a1b2c3d4-0001-0001-0001-000000000001", "username": "alice", "email": "alice@example.com" } } }createPost
Section titled “createPost”mutation { createPost(input: { title: "Hello World" content: "This is my first post." authorId: "a1b2c3d4-0001-0001-0001-000000000001" }) { id title slug author { username } }}{ "data": { "createPost": { "id": "b2c3d4e5-0002-0002-0002-000000000002", "title": "Hello World", "slug": "hello-world", "author": { "username": "alice" } } } }publishPost
Section titled “publishPost”mutation { publishPost(input: { postId: "b2c3d4e5-0002-0002-0002-000000000002" }) { id title isPublished }}{ "data": { "publishPost": { "id": "b2c3d4e5-0002-0002-0002-000000000002", "title": "Hello World", "isPublished": true } } }The response data comes from the view — v_post is queried by the id the function returns. The same view that serves read queries also serves mutation responses.
REST Annotations on Mutations
Section titled “REST Annotations on Mutations”Transport annotations work on mutations the same way as queries. Add rest_path and rest_method to expose a mutation as a REST endpoint:
@fraiseql.mutation( sql_source="create_post", operation="CREATE", rest_path="/posts", rest_method="POST",)def create_post(title: str, author_id: UUID) -> Post: ...With that annotation, the mutation is reachable via REST alongside GraphQL. The default REST path is /rest/v1. Configure with [rest] path to use /api or any other value:
curl -X POST http://localhost:8080/rest/v1/posts \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"title": "Hello World", "authorId": "..."}'The GraphQL mutation continues to work unchanged — REST annotations are additive.
What’s Next
Section titled “What’s Next”- Custom Queries — Views with filtering and pagination
- Threaded Comments — Recursive CTEs for nested data
- Projection Tables — CQRS materialization with
tv_*tables - Observers — Trigger side effects after mutations
- Mutations — Deep dive into the mutation system
- Naming Conventions — Naming conventions reference