# GitHub Projects v2 GraphQL API: a working guide

> **TL;DR** GitHub Projects is driven through GraphQL at api.github.com/graphql. Authenticate with a token that has the project scope, look up the project node id, page through items 100 at a time with fieldValues, write a cell with updateProjectV2ItemFieldValue (one value key per call, clearProjectV2ItemFieldValue to empty it), add an issue with addProjectV2ItemById, and link sub-issues with addSubIssue on the parent issue.

GitHub Projects has a GraphQL API, and it is the API that covers the whole product: projects, fields, items, field values and the issue relationships around them. Every example on this page is a query or mutation that Gitsu itself sends to GitHub, trimmed where it selects more than the example needs. Each block names the file it came from, so you can read the full version in context.

Gitsu is a desktop and web client for GitHub Projects, and it keeps no copy of your board: every read and every write goes through this API. That makes its client a useful reference for the parts GitHub's documentation leaves to you, such as pagination cost, clearing a value and which id a mutation wants. If you would rather see the result than the requests, [getting started with the Gitsu Web App](/docs/getting-started) shows the same boards in the browser.

## How do I authenticate against the Projects API?

Send a bearer token to `https://api.github.com/graphql` in an `Authorization: Bearer <token>` header. GitHub documents two OAuth scopes for Projects: `read:project` for reading and `project` for reading and writing. A classic personal access token with `project` is enough to run every example below against your own boards.

Gitsu's sign-in asks for `project repo read:org read:user`, the same string in its desktop device flow and its web redirect flow. `project` covers the board, `repo` covers the issues and pull requests on it, `read:org` lets it list organization boards, and `read:user` reads your profile. It does not ask for `notifications`, so it cannot change whether you watch a repository.

Rate limits are counted in points per hour, and a query's cost depends on how many nodes it asks for. You can select `rateLimit { limit remaining resetAt }` on any query to see your budget. You cannot select it on a mutation: GitHub declares `rateLimit` on `Query` only, and asking for it inside a mutation fails the whole document with `Field 'rateLimit' does not exist on type 'Mutation'`, so nothing is written. Gitsu reads the `x-ratelimit-*` response headers after a write instead.

## How do I find a project's node id?

Every Projects query and mutation takes the project's global node id, a string starting `PVT_`, not the number in its URL. List the projects your token can see and read the `id` off each one. This is the query Gitsu runs to fill its project picker, with the owner fragments trimmed.

Source: `src/github/queries/projects.ts` (`projectsQuery`)

```graphql
query ViewerProjects($first: Int!, $after: String) {
  viewer {
    projectsV2(
      first: $first
      after: $after
      minPermissionLevel: READ
      orderBy: { field: NUMBER, direction: ASC }
    ) {
      nodes {
        id
        number
        title
        url
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
  rateLimit {
    limit
    remaining
    resetAt
  }
}
```

The same selection works under `organization(login: $login)` for an organization's boards. Keep the `orderBy`: a connection paged without a stable sort can repeat or skip nodes between pages.

Once you have the id, read the project's fields. Writes need field ids, and single select and iteration writes also need option or iteration ids, which only this query returns.

Source: `src/github/queries/project.ts` (`projectQuery`, iteration and multi-select fragments trimmed)

```graphql
query Project($id: ID!, $first: Int!) {
  node(id: $id) {
    ... on ProjectV2 {
      id
      title
      fields(first: $first) {
        nodes {
          ... on ProjectV2Field {
            id
            name
            dataType
          }
          ... on ProjectV2SingleSelectField {
            id
            name
            dataType
            options {
              id
              name
            }
          }
        }
      }
    }
  }
}
```

GitHub's own field dialog offers six custom field types: text, number, date, single select, multi select and iteration. For an iteration field, read both `configuration.iterations` and `configuration.completedIterations`; the first holds only current and future iterations, so a filter naming last sprint needs the second.

## How do I read items and their field values?

Page through `items` 100 at a time, which is the most GitHub allows for `first`, passing `pageInfo.endCursor` back as `$after` until `hasNextPage` is false. Each item has `fieldValues`, a union with one member per field type, and you select the members you want with inline fragments.

Source: `src/github/queries/items.ts` (`itemFieldValuesQuery`; `$archivedStates`, `dataType`, the multi-select fragment and `rateLimit` trimmed)

```graphql
query ProjectItemFieldValues($id: ID!, $first: Int!, $after: String) {
  node(id: $id) {
    ... on ProjectV2 {
      items(first: $first, after: $after) {
        nodes {
          id
          fieldValues(first: 50) {
            nodes {
              __typename
              ... on ProjectV2ItemFieldTextValue {
                field {
                  ... on ProjectV2FieldCommon {
                    id
                  }
                }
                text
              }
              ... on ProjectV2ItemFieldNumberValue {
                field {
                  ... on ProjectV2FieldCommon {
                    id
                  }
                }
                number
              }
              ... on ProjectV2ItemFieldDateValue {
                field {
                  ... on ProjectV2FieldCommon {
                    id
                  }
                }
                date
              }
              ... on ProjectV2ItemFieldSingleSelectValue {
                field {
                  ... on ProjectV2FieldCommon {
                    id
                  }
                }
                optionId
                name
              }
              ... on ProjectV2ItemFieldIterationValue {
                field {
                  ... on ProjectV2FieldCommon {
                    id
                  }
                }
                iterationId
                title
                startDate
              }
            }
            pageInfo {
              hasNextPage
            }
          }
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  }
}
```

Nested connections such as `fieldValues` cannot be paged with the same cursor, so pick a size that covers your project and check their `hasNextPage`. Gitsu asks for 50 values per item and reports a truncated page rather than dropping values silently.

Cost is the thing to watch. A page is roughly 100 items times the nodes nested in each, so Gitsu splits the load in two: one walk for each item's issue content (title, assignees, labels, state) and a second walk for the project's own field values. The board can draw from the first while the second is still in flight, and both walk the same connection in the same order so the ids line up.

`items` excludes archived items unless you pass `archivedStates`. Built-in columns such as labels, assignees and milestone are read from the issue itself (`content { ... on Issue { labels { ... } } }`), not from `fieldValues`.

## How do I update a field value?

Call `updateProjectV2ItemFieldValue` with the project id, the project item id and the field id, plus a `value` object with exactly one key. The item id is the `ProjectV2Item` id from the items query, not the issue's id.

Source: `src/github/mutations.ts` (`setFieldValueMutation`)

```graphql
mutation SetFieldValue($input: UpdateProjectV2ItemFieldValueInput!) {
  updateProjectV2ItemFieldValue(input: $input) {
    projectV2Item {
      id
    }
  }
}
```

The `value` key depends on the field type. Gitsu's client picks one per type:

| Field type    | `value` key            | What it takes                      |
| ------------- | ---------------------- | ---------------------------------- |
| Text          | `text`                 | A string                           |
| Number        | `number`               | A number                           |
| Date          | `date`                 | An ISO date, such as `2026-09-24`  |
| Single select | `singleSelectOptionId` | An option id from the fields query |
| Multi select  | `multiSelectOptionIds` | A list of option ids               |
| Iteration     | `iterationId`          | An iteration id                    |

Sending two keys is an error. There is also no key that means empty, so clearing a cell is a different mutation that takes the same three ids.

Source: `src/github/mutations.ts` (`clearFieldValueMutation`)

```graphql
mutation ClearFieldValue($input: ClearProjectV2ItemFieldValueInput!) {
  clearProjectV2ItemFieldValue(input: $input) {
    projectV2Item {
      id
    }
  }
}
```

Labels, assignees and milestone are not project fields, and `updateProjectV2ItemFieldValue` cannot write them. Change those on the issue, with `addLabelsToLabelable`, `addAssigneesToAssignable` or `updateIssue`.

## How do I add an issue to a project?

Call `addProjectV2ItemById` with the project id and the issue's node id as `contentId`. It returns the new project item. Gitsu also selects the new item's field values, because a project workflow such as "Item added to project" may set Status the moment the item lands, and reading it here saves a second request.

Source: `src/github/mutations.ts` (`addItemToProjectMutation`, fragments trimmed to one)

```graphql
mutation AddItemToProject($input: AddProjectV2ItemByIdInput!) {
  addProjectV2ItemById(input: $input) {
    item {
      id
      fieldValues(first: 100) {
        nodes {
          __typename
          ... on ProjectV2ItemFieldSingleSelectValue {
            field {
              ... on ProjectV2FieldCommon {
                id
              }
            }
            optionId
            name
          }
        }
      }
    }
  }
}
```

To create a new issue and put it on a board, run `createIssue` first, then this mutation with the returned issue id. Draft issues have their own `addProjectV2DraftIssue` mutation; Gitsu does not create drafts.

## How do I link sub-issues?

Sub-issue links live on the parent issue, not on the project item. `addSubIssue` takes the parent's issue node id as `issueId` and the child's as `subIssueId`. `removeSubIssue` takes the same pair and only removes the link; the child issue is not deleted.

Source: `src/github/mutations.ts` (`addSubIssueMutation`)

```graphql
mutation AddSubIssue($input: AddSubIssueInput!) {
  addSubIssue(input: $input) {
    subIssue {
      id
    }
  }
}
```

To read the hierarchy, select `parent`, `subIssuesSummary { completed total }` and `subIssues(first: N)` on an `Issue`. `reprioritizeSubIssue` reorders a child within its parent's list, with `afterId` naming the sibling it should follow. Blocking relationships work the same way through `addBlockedBy` and `removeBlockedBy`; `Issue.blocking` is the computed inverse and has no mutation of its own.

## Which id does each mutation want, and how do errors come back?

Most failed Projects writes come from passing the wrong id. There are three kinds, and they are not interchangeable:

| Id                    | Where you get it                           | Used by                                                   |
| --------------------- | ------------------------------------------ | --------------------------------------------------------- |
| Project (`PVT_…`)     | `projectsV2` or `node(id:)` on the project | Every project mutation, as `projectId`                    |
| Project item          | `items { nodes { id } }`                   | Field value writes, archive, delete, as `itemId`          |
| Issue or pull request | `content { ... on Issue { id } }`          | `addProjectV2ItemById` as `contentId`, sub-issues, labels |

A GraphQL response can carry an `errors` array beside a `data` object, so a successful HTTP status does not mean the query worked. Read `errors` on every response. Gitsu treats a response whose only errors are `NOT_FOUND` as a partial success when it asked for several nodes by id at once, since the null nodes are simply the missing ones. A `RATE_LIMITED` error means the hourly budget is spent; wait for the reset time rather than retrying.

## What can't the API do?

The GraphQL schema lists 258 mutations, and 32 of them work on Projects. Several things you can do on github.com have no mutation:

- **Workflows** can be read and deleted, but not created, edited or turned on and off. Configure them in the project's Workflows page.
- **Insights charts** have no field on `ProjectV2` and no mutation.
- **Export view data** (CSV) is only in the github.com view menu.
- **Sort order** cannot be written to a view: `UpdateProjectV2ViewInput` has no sort field, though you can read a view's `sortByFields`.

The REST side has the opposite gap. GitHub's classic REST projects endpoints were built for the old project boards, and every Projects example on this page is GraphQL. Gitsu's only REST calls are milestone writes: the GraphQL schema has no milestone mutation at all, so creating, editing, closing or deleting a milestone goes through REST.

## What does this look like in Gitsu?

Gitsu is these same calls behind a fast interface. Its board patches the screen the moment you change a value, sends the mutation above, and rolls the change back with a message if GitHub refuses it. The [guide to GitHub project board examples](/guides/github-project-board-examples) shows five board setups built on these fields, and the [Gitsu keyboard shortcuts](/docs/keyboard-shortcuts) page lists the keys that trigger each write.

## Frequently asked questions

### Which token scope does the Projects GraphQL API need?

GitHub documents read:project for reading projects and project for reading and writing them. Gitsu asks for project repo read:org read:user, because it also edits the issues on the board and lists organization boards.

### How do I set a Status column through the API?

Status is a single select field. Read the field id and its option ids from the project fields, then call updateProjectV2ItemFieldValue with singleSelectOptionId set to the option id, using the project item id, not the issue id.

### How do I clear a field value?

Call clearProjectV2ItemFieldValue with the project, item and field ids. updateProjectV2ItemFieldValue has no empty value, so sending null or an empty string does not clear a cell.

### Can I create project views or workflows through the API?

Views have create, update and delete mutations in the schema. Workflows can only be read and deleted; there is no mutation to create, edit or enable one, so workflows are configured on github.com.

### Is there a REST API for GitHub Projects?

The classic REST projects endpoints served the old project boards. GraphQL is the API every Projects example here uses, and the one Gitsu uses for every project read and write. Gitsu only calls REST for milestones, which GraphQL cannot write.
