actualbudget/actual · error

Category '${category.name}' already exists in group '${categ

Error message

Category '${category.name}' already exists in group '${category.cat_group}'

What it means

insertCategory enforces unique non-deleted category names within a category group (case-insensitive). Before inserting, it queries categories with the same cat_group and UPPER(name) where tombstone = 0; if one exists, it throws. The group is referenced by id, so the message shows an id, not a friendly group name.

Source

Thrown at packages/loot-core/src/server/db/index.ts:456

  await Promise.all(categories.map(cat => deleteCategory(cat, transferId)));
  await delete_('category_groups', group.id);
}

export async function insertCategory(
  category: WithRequired<Partial<DbCategory>, 'name' | 'cat_group'>,
  { atEnd }: { atEnd?: boolean | undefined } = { atEnd: undefined },
): Promise<DbCategory['id']> {
  let sort_order;

  let id_: DbCategory['id'];
  await batchMessages(async () => {
    // Dont allow duplicated names in groups
    const existingCatInGroup = await first<Pick<DbCategory, 'id'>>(
      `SELECT id FROM categories WHERE cat_group = ? and UPPER(name) = ? and tombstone = 0 LIMIT 1`,
      [category.cat_group, category.name.toUpperCase()],
    );
    if (existingCatInGroup) {
      throw new Error(
        `Category '${category.name}' already exists in group '${category.cat_group}'`,
      );
    }

    if (atEnd) {
      const lastCat = await first<Pick<DbCategory, 'sort_order'>>(`
        SELECT sort_order FROM categories WHERE tombstone = 0 ORDER BY sort_order DESC, id DESC LIMIT 1
      `);
      sort_order = (lastCat ? lastCat.sort_order : 0) + SORT_INCREMENT;
    } else {
      // Unfortunately since we insert at the beginning, we need to shove
      // the sort orders to make sure there's room for it
      const categories = await all<Pick<DbCategory, 'id' | 'sort_order'>>(
        `SELECT id, sort_order FROM categories WHERE cat_group = ? AND tombstone = 0 ORDER BY sort_order, id`,
        [category.cat_group],
      );

      const { updates, sort_order: order } = shoveSortOrders(

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Rename the new category or pick a different group before calling insertCategory
  2. Check for an existing category first: SELECT id FROM categories WHERE cat_group = ? AND UPPER(name) = ? AND tombstone = 0
  3. If the old category was deleted (tombstoned), restore/reuse it instead of creating a new one
  4. Handle the error in the client and surface a 'name already in use' message to the user

Example fix

// before
await aqlQuery.insertCategory({ name: 'Groceries', cat_group: groupId });
// after
const existing = await runQuery(q('categories').filter({ cat_group: groupId, name: 'Groceries' }).select('id'));
if (existing.length === 0) {
  await aqlQuery.insertCategory({ name: 'Groceries', cat_group: groupId });
}
Defensive patterns

Strategy: validation

Validate before calling

const dup = await runQuery(q('categories').filter({ cat_group: groupId }).select('name'));
const isDuplicate = dup.some(c => c.name.toLowerCase() === newName.toLowerCase());
if (isDuplicate) throw new Error(`Category '${newName}' already exists in this group`);

Type guard

function isUniqueName<T extends { name: string }>(list: T[], name: string): boolean {
  return !list.some(item => item.name.toUpperCase() === name.toUpperCase());
}

Try / catch

try {
  await aqlQuery.insertCategory(category);
} catch (e) {
  if (e.message.includes('already exists in group')) {
    notifyUser('Category name already used in this group');
  } else throw e;
}

Prevention

When it happens

Trigger: Calling insertCategory with a name that duplicates (ignoring case) an existing non-deleted category in the same group; also occurs when a deleted-but-tombstoned category is excluded so a name that 'should' be free is not, or when the client retries a create that already succeeded.

Common situations: UI autocomplete not filtering duplicates, batch imports of categories with repeated names in one group, creating 'Groceries' when 'groceries' already exists in the group, concurrent clients syncing the same new category.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/e8a09e6abb3052e3. Report an issue: GitHub.