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
- Rename the new category or pick a different group before calling insertCategory
- Check for an existing category first: SELECT id FROM categories WHERE cat_group = ? AND UPPER(name) = ? AND tombstone = 0
- If the old category was deleted (tombstoned), restore/reuse it instead of creating a new one
- 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
- Check for case-insensitive name duplicates within the group before creating
- Filter out tombstoned categories in your duplicate check so deleted names are reusable
- Disable duplicate submit buttons to avoid double-create retries
- Surface live name validation in the UI as the user types
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
- An '${existingGroup.name}' account group already exists.
- No update fields provided. Use --name or --hidden.
- Only one `up to` allowed per category
- Only one spend template is allowed per category
- Only one #goal is allowed per category
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/e8a09e6abb3052e3.
Report an issue: GitHub.