diff --git a/aleksis/apps/kolego/frontend/components/AbsenceReasonTags.vue b/aleksis/apps/kolego/frontend/components/AbsenceReasonTags.vue
new file mode 100644
index 0000000000000000000000000000000000000000..38a3999362347cc3dab490d29c4bad77360e2b36
--- /dev/null
+++ b/aleksis/apps/kolego/frontend/components/AbsenceReasonTags.vue
@@ -0,0 +1,80 @@
+<script setup>
+import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue";
+</script>
+
+<template>
+  <v-container>
+    <inline-c-r-u-d-list
+      :headers="headers"
+      :i18n-key="i18nKey"
+      create-item-i18n-key="kolego.absence_reason_tag.create"
+      :gql-query="gqlQuery"
+      :gql-create-mutation="gqlCreateMutation"
+      :gql-patch-mutation="gqlPatchMutation"
+      :gql-delete-mutation="gqlDeleteMutation"
+      :default-item="defaultItem"
+    >
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #name.field="{ attrs, on }">
+        <div aria-required="true">
+          <v-text-field
+            v-bind="attrs"
+            v-on="on"
+            :rules="$rules().required.build()"
+          />
+        </div>
+      </template>
+
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #shortName.field="{ attrs, on }">
+        <div aria-required="true">
+          <v-text-field
+            v-bind="attrs"
+            v-on="on"
+            :rules="$rules().required.build()"
+          />
+        </div>
+      </template>
+    </inline-c-r-u-d-list>
+  </v-container>
+</template>
+
+<script>
+import formRulesMixin from "aleksis.core/mixins/formRulesMixin.js";
+import {
+  absenceReasonTags,
+  createAbsenceReasonTags,
+  deleteAbsenceReasonTags,
+  updateAbsenceReasonTags,
+} from "./absenceReasonTags.graphql";
+
+export default {
+  name: "AbsenceReasonTags",
+  mixins: [formRulesMixin],
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("kolego.absence_reason_tag.short_name"),
+          value: "shortName",
+        },
+        {
+          text: this.$t("kolego.absence_reason_tag.name"),
+          value: "name",
+        },
+      ],
+      i18nKey: "kolego.absence_reason_tag",
+      gqlQuery: absenceReasonTags,
+      gqlCreateMutation: createAbsenceReasonTags,
+      gqlPatchMutation: updateAbsenceReasonTags,
+      gqlDeleteMutation: deleteAbsenceReasonTags,
+      defaultItem: {
+        shortName: "",
+        name: "",
+      },
+    };
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/apps/kolego/frontend/components/AbsenceReasonTagsField.vue b/aleksis/apps/kolego/frontend/components/AbsenceReasonTagsField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..07740e2fa818f76d770dacb4d1a3384e7b01909d
--- /dev/null
+++ b/aleksis/apps/kolego/frontend/components/AbsenceReasonTagsField.vue
@@ -0,0 +1,76 @@
+<script setup>
+import ForeignKeyField from "aleksis.core/components/generic/forms/ForeignKeyField.vue";
+</script>
+
+<template>
+  <foreign-key-field
+    v-bind="$attrs"
+    v-on="$listeners"
+    :fields="headers"
+    create-item-i18n-key="kolego.absence_reason_tag.create"
+    :gql-query="gqlQuery"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-patch-mutation="{}"
+    :default-item="defaultItem"
+    multiple
+    chips
+  >
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #name.field="{ attrs, on }">
+      <div aria-required="true">
+        <v-text-field
+          v-bind="attrs"
+          v-on="on"
+          :rules="$rules().required.build()"
+        />
+      </div>
+    </template>
+
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #shortName.field="{ attrs, on }">
+      <div aria-required="true">
+        <v-text-field
+          v-bind="attrs"
+          v-on="on"
+          :rules="$rules().required.build()"
+        />
+      </div>
+    </template>
+  </foreign-key-field>
+</template>
+
+<script>
+import {
+  allAbsenceReasonTags,
+  createAbsenceReasonTags,
+} from "./absenceReasonTags.graphql";
+import formRulesMixin from "aleksis.core/mixins/formRulesMixin.js";
+
+export default {
+  name: "AbsenceReasonTagsField",
+  mixins: [formRulesMixin],
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("kolego.absence_reason_tag.short_name"),
+          value: "shortName",
+        },
+        {
+          text: this.$t("kolego.absence_reason_tag.name"),
+          value: "name",
+        },
+      ],
+      i18nKey: "kolego.absence_reason_tag",
+      gqlQuery: allAbsenceReasonTags,
+      gqlCreateMutation: createAbsenceReasonTags,
+      defaultItem: {
+        name: "",
+        shortName: "",
+      },
+    };
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/apps/kolego/frontend/components/AbsenceReasons.vue b/aleksis/apps/kolego/frontend/components/AbsenceReasons.vue
index 3af13e90396c3a4ed40df8e8a6f6296068ffa4dd..3774a427c26c33a0874640a66776d79ca98cd1e8 100644
--- a/aleksis/apps/kolego/frontend/components/AbsenceReasons.vue
+++ b/aleksis/apps/kolego/frontend/components/AbsenceReasons.vue
@@ -1,6 +1,7 @@
 <script setup>
 import ColorField from "aleksis.core/components/generic/forms/ColorField.vue";
 import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue";
+import AbsenceReasonTagsField from "./AbsenceReasonTagsField.vue";
 </script>
 
 <template>
@@ -69,6 +70,15 @@ import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue";
           persistent-hint
         />
       </template>
+
+      <template #tags="{ item }">
+        <span v-if="item.tags.length == 0">–</span>
+        <v-chip v-for="tag in item.tags" :key="tag.id">{{ tag.name }}</v-chip>
+      </template>
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #tags.field="{ attrs, on }">
+        <absence-reason-tags-field v-bind="attrs" v-on="on" />
+      </template>
     </inline-c-r-u-d-list>
   </v-container>
 </template>
@@ -104,6 +114,10 @@ export default {
           text: this.$t("kolego.absence_reason.default"),
           value: "default",
         },
+        {
+          text: this.$t("kolego.absence_reason.tags"),
+          value: "tags",
+        },
       ],
       i18nKey: "kolego.absence_reason",
       gqlQuery: absenceReasons,
diff --git a/aleksis/apps/kolego/frontend/components/absenceReasonTags.graphql b/aleksis/apps/kolego/frontend/components/absenceReasonTags.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..34309da27040f904578a9987dfffd273d4e45a61
--- /dev/null
+++ b/aleksis/apps/kolego/frontend/components/absenceReasonTags.graphql
@@ -0,0 +1,49 @@
+query absenceReasonTags($orderBy: [String], $filters: JSONString) {
+  items: absenceReasonTags(orderBy: $orderBy, filters: $filters) {
+    id
+    shortName
+    name
+    canEdit
+    canDelete
+  }
+}
+
+query allAbsenceReasonTags($orderBy: [String], $filters: JSONString) {
+  items: allAbsenceReasonTags(orderBy: $orderBy, filters: $filters) {
+    id
+    shortName
+    name
+    canEdit
+    canDelete
+  }
+}
+
+mutation createAbsenceReasonTags($input: [BatchCreateAbsenceReasonTagInput]!) {
+  createAbsenceReasonTags(input: $input) {
+    items: absenceReasonTags {
+      id
+      shortName
+      name
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation deleteAbsenceReasonTags($ids: [ID]!) {
+  deleteAbsenceReasonTags(ids: $ids) {
+    deletionCount
+  }
+}
+
+mutation updateAbsenceReasonTags($input: [BatchPatchAbsenceReasonTagInput]!) {
+  updateAbsenceReasonTags(input: $input) {
+    items: absenceReasonTags {
+      id
+      shortName
+      name
+      canEdit
+      canDelete
+    }
+  }
+}
diff --git a/aleksis/apps/kolego/frontend/components/absenceReasons.graphql b/aleksis/apps/kolego/frontend/components/absenceReasons.graphql
index fa713b354a9bb42173bd39b3c1039c29e0518fee..f12be47606f7850ab2b9150c079e3847ca0f5012 100644
--- a/aleksis/apps/kolego/frontend/components/absenceReasons.graphql
+++ b/aleksis/apps/kolego/frontend/components/absenceReasons.graphql
@@ -7,6 +7,11 @@ query absenceReasons($orderBy: [String], $filters: JSONString) {
     default
     canEdit
     canDelete
+    tags {
+      id
+      name
+      shortName
+    }
   }
 }
 
@@ -20,6 +25,11 @@ mutation createAbsenceReasons($input: [BatchCreateAbsenceReasonInput]!) {
       default
       canEdit
       canDelete
+      tags {
+        id
+        name
+        shortName
+      }
     }
   }
 }
@@ -40,6 +50,11 @@ mutation updateAbsenceReasons($input: [BatchPatchAbsenceReasonInput]!) {
       default
       canEdit
       canDelete
+      tags {
+        id
+        name
+        shortName
+      }
     }
   }
 }
diff --git a/aleksis/apps/kolego/frontend/index.js b/aleksis/apps/kolego/frontend/index.js
index e1369d87d5a697e104a6d454fd015f6781d8f39c..260b3d9cc754548bb730d2bad05cc2de2d19d546 100644
--- a/aleksis/apps/kolego/frontend/index.js
+++ b/aleksis/apps/kolego/frontend/index.js
@@ -31,5 +31,17 @@ export default {
         permission: "kolego.view_absencereasons_rule",
       },
     },
+    {
+      path: "absence_reason_tags",
+      component: () => import("./components/AbsenceReasonTags.vue"),
+      name: "kolego.absence_reason_tags",
+      meta: {
+        inMenu: true,
+        titleKey: "kolego.absence_reason_tag.menu_title",
+        icon: "mdi-tag-multiple-outline",
+        iconActive: "mdi-tag-multiple",
+        permission: "kolego.view_absencereasontags_rule",
+      },
+    },
   ],
 };
diff --git a/aleksis/apps/kolego/frontend/messages/en.json b/aleksis/apps/kolego/frontend/messages/en.json
index acc168b7b6f115d634742ea12eb38ff5ac2167de..2546390d2bea10c2830408cdd4e1c17ee3eb5bae 100644
--- a/aleksis/apps/kolego/frontend/messages/en.json
+++ b/aleksis/apps/kolego/frontend/messages/en.json
@@ -17,7 +17,15 @@
       "colour": "Colour",
       "default": "Default Absence Reason",
       "default_helptext": "Will disable previous default when enabled",
-      "present": "Present"
+      "present": "Present",
+      "tags": "Tags"
+    },
+    "absence_reason_tag": {
+      "menu_title": "Absence Reason Tags",
+      "title_plural": "Absence Reason Tags",
+      "create": "Create absence reason tag",
+      "short_name": "Short name",
+      "name": "Name"
     }
   }
 }
diff --git a/aleksis/apps/kolego/migrations/0004_absencereasontag_absencereason_tags.py b/aleksis/apps/kolego/migrations/0004_absencereasontag_absencereason_tags.py
new file mode 100644
index 0000000000000000000000000000000000000000..17da82844887d7e072bfeaf560f2dfb3dd5b6b7f
--- /dev/null
+++ b/aleksis/apps/kolego/migrations/0004_absencereasontag_absencereason_tags.py
@@ -0,0 +1,32 @@
+# Generated by Django 4.2.13 on 2024-07-16 11:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('kolego', '0003_refactor_absence'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AbsenceReasonTag',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('managed_by_app_label', models.CharField(blank=True, editable=False, max_length=255, verbose_name='App label of app responsible for managing this instance')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('short_name', models.CharField(max_length=255, unique=True, verbose_name='Short name')),
+                ('name', models.CharField(max_length=255, verbose_name='Name')),
+            ],
+            options={
+                'verbose_name': 'Absence reason tag',
+                'verbose_name_plural': 'Absence reason tags',
+            },
+        ),
+        migrations.AddField(
+            model_name='absencereason',
+            name='tags',
+            field=models.ManyToManyField(blank=True, to='kolego.absencereasontag', verbose_name='Tags'),
+        ),
+    ]
diff --git a/aleksis/apps/kolego/models/__init__.py b/aleksis/apps/kolego/models/__init__.py
index 1abd1e0c1f5f4c21ca9c4bd697eddf5b7d3177e4..1d41f22634f84a1e4fe97a250c47e1340406b85b 100644
--- a/aleksis/apps/kolego/models/__init__.py
+++ b/aleksis/apps/kolego/models/__init__.py
@@ -1 +1 @@
-from .absence import Absence, AbsenceReason  # noqa: F401
+from .absence import Absence, AbsenceReason, AbsenceReasonTag  # noqa: F401
diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py
index b43ff9a6f2c0187abb3519a3daa2ab14a67d93cf..1421b69dc5e144a91f91b9f6d22c9f2f5d36f7c1 100644
--- a/aleksis/apps/kolego/models/absence.py
+++ b/aleksis/apps/kolego/models/absence.py
@@ -12,6 +12,21 @@ from aleksis.core.models import FreeBusy
 from ..managers import AbsenceQuerySet
 
 
+class AbsenceReasonTag(ExtensibleModel):
+    short_name = models.CharField(verbose_name=_("Short name"), max_length=255, unique=True)
+    name = models.CharField(verbose_name=_("Name"), max_length=255)
+
+    def __str__(self):
+        if self.name:
+            return f"{self.short_name} ({self.name})"
+        else:
+            return self.short_name
+
+    class Meta:
+        verbose_name = _("Absence reason tag")
+        verbose_name_plural = _("Absence reason tags")
+
+
 class AbsenceReason(ExtensibleModel):
     short_name = models.CharField(verbose_name=_("Short name"), max_length=255, unique=True)
     name = models.CharField(verbose_name=_("Name"), max_length=255)
@@ -29,6 +44,10 @@ class AbsenceReason(ExtensibleModel):
 
     default = models.BooleanField(verbose_name=_("Default Reason"), default=False)
 
+    tags = models.ManyToManyField(
+        AbsenceReasonTag, blank=True, verbose_name=_("Tags"), related_name="absence_reasons"
+    )
+
     def __str__(self):
         if self.name:
             return f"{self.short_name} ({self.name})"
diff --git a/aleksis/apps/kolego/rules.py b/aleksis/apps/kolego/rules.py
index 893d04d3784495e2c523ee10ef451a25d72f3348..535b0f35d5ad7a8668a5ec5aa62cb1dbe4bd7abc 100644
--- a/aleksis/apps/kolego/rules.py
+++ b/aleksis/apps/kolego/rules.py
@@ -1,6 +1,6 @@
 import rules
 
-from aleksis.apps.kolego.models.absence import Absence, AbsenceReason
+from aleksis.apps.kolego.models.absence import Absence, AbsenceReason, AbsenceReasonTag
 from aleksis.core.util.predicates import (
     has_any_object,
     has_global_perm,
@@ -58,5 +58,34 @@ delete_absencereason_predicate = has_person & (
 )
 rules.add_perm("kolego.delete_absencereason_rule", delete_absencereason_predicate)
 
-view_menu_predicate = has_person & (view_absences_predicate | view_absencereasons_predicate)
+view_absencereasontags_predicate = has_person & (
+    has_global_perm("kolego.view_absencereasontag")
+    | has_any_object("kolego.view_absencereasontag", AbsenceReasonTag)
+)
+rules.add_perm("kolego.view_absencereasontags_rule", view_absencereasontags_predicate)
+
+view_absencereasontag_predicate = has_person & (
+    has_global_perm("kolego.view_absencereasontag")
+    | has_object_perm("kolego.view_absencereasontag")
+)
+rules.add_perm("kolego.view_absencereasontag_rule", view_absencereasontag_predicate)
+
+create_absencereasontag_predicate = has_person & (has_global_perm("kolego.add_absencereasontag"))
+rules.add_perm("kolego.create_absencereasontag_rule", create_absencereasontag_predicate)
+
+edit_absencereasontag_predicate = has_person & (
+    has_global_perm("kolego.change_absencereasontag")
+    | has_object_perm("kolego.change_absencereasontag")
+)
+rules.add_perm("kolego.edit_absencereasontag_rule", edit_absencereasontag_predicate)
+
+delete_absencereasontag_predicate = has_person & (
+    has_global_perm("kolego.delete_absencereasontag")
+    | has_object_perm("kolego.delete_absencereasontag")
+)
+rules.add_perm("kolego.delete_absencereasontag_rule", delete_absencereasontag_predicate)
+
+view_menu_predicate = has_person & (
+    view_absences_predicate | view_absencereasons_predicate | view_absencereasontags_predicate
+)
 rules.add_perm("kolego.view_menu_rule", view_menu_predicate)
diff --git a/aleksis/apps/kolego/schema/__init__.py b/aleksis/apps/kolego/schema/__init__.py
index 4fd07d96cd16188d60c035d0a46c247fb492d091..3b7f58ce62fe6cec550f907ea87fa56929c348dc 100644
--- a/aleksis/apps/kolego/schema/__init__.py
+++ b/aleksis/apps/kolego/schema/__init__.py
@@ -2,6 +2,7 @@ from django.apps import apps
 
 import graphene
 
+from aleksis.apps.kolego.models.absence import AbsenceReasonTag
 from aleksis.core.schema.base import FilterOrderList
 
 from .absence import (
@@ -11,6 +12,10 @@ from .absence import (
     AbsenceReasonBatchCreateMutation,
     AbsenceReasonBatchDeleteMutation,
     AbsenceReasonBatchPatchMutation,
+    AbsenceReasonTagBatchCreateMutation,
+    AbsenceReasonTagBatchDeleteMutation,
+    AbsenceReasonTagBatchPatchMutation,
+    AbsenceReasonTagType,
     AbsenceReasonType,
     AbsenceType,
 )
@@ -20,10 +25,15 @@ class Query(graphene.ObjectType):
     app_name = graphene.String()
     absences = FilterOrderList(AbsenceType)
     absence_reasons = FilterOrderList(AbsenceReasonType)
+    absence_reason_tags = FilterOrderList(AbsenceReasonTagType)
+    all_absence_reason_tags = FilterOrderList(AbsenceReasonTagType)
 
     def resolve_app_name(root, info, **kwargs) -> str:
         return apps.get_app_config("kolego").verbose_name
 
+    def resolve_all_absence_reason_tags(root, info, **kwargs):
+        return AbsenceReasonTag.objects.managed_and_unmanaged()
+
 
 class Mutation(graphene.ObjectType):
     create_absences = AbsenceBatchCreateMutation.Field()
@@ -33,3 +43,7 @@ class Mutation(graphene.ObjectType):
     create_absence_reasons = AbsenceReasonBatchCreateMutation.Field()
     delete_absence_reasons = AbsenceReasonBatchDeleteMutation.Field()
     update_absence_reasons = AbsenceReasonBatchPatchMutation.Field()
+
+    create_absence_reason_tags = AbsenceReasonTagBatchCreateMutation.Field()
+    delete_absence_reason_tags = AbsenceReasonTagBatchDeleteMutation.Field()
+    update_absence_reason_tags = AbsenceReasonTagBatchPatchMutation.Field()
diff --git a/aleksis/apps/kolego/schema/absence.py b/aleksis/apps/kolego/schema/absence.py
index eb8f98654ec6834a7e206f87c0adc98f58f5cafe..6eb5f8015bdc4f263042eaa9a844e396001fbfb0 100644
--- a/aleksis/apps/kolego/schema/absence.py
+++ b/aleksis/apps/kolego/schema/absence.py
@@ -1,4 +1,5 @@
 from datetime import timezone
+from typing import Iterable, Union
 
 from django.conf import settings
 
@@ -14,18 +15,35 @@ from aleksis.core.schema.base import (
     PermissionsTypeMixin,
 )
 
-from ..models import Absence, AbsenceReason
+from ..models import Absence, AbsenceReason, AbsenceReasonTag
+
+
+class AbsenceReasonTagType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
+    class Meta:
+        model = AbsenceReasonTag
+        fields = ("id", "short_name", "name")
+        filter_fields = {
+            "short_name": ["icontains", "exact"],
+            "name": ["icontains", "exact"],
+        }
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        return get_objects_for_user(info.context.user, "kolego.view_absencereasontag", queryset)
 
 
 class AbsenceReasonType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
     class Meta:
         model = AbsenceReason
-        fields = ("id", "short_name", "name", "colour", "default")
+        fields = ("id", "short_name", "name", "colour", "default", "tags")
         filter_fields = {
             "short_name": ["icontains", "exact"],
             "name": ["icontains", "exact"],
         }
 
+    def resolve_tags(root, info, **kwargs):
+        return root.tags.managed_and_unmanaged().filter(absence_reasons=root)
+
     @classmethod
     def get_queryset(cls, queryset, info):
         if not info.context.user.has_perm("kolego.fetch_absencereasons_rule"):
@@ -129,10 +147,18 @@ class AbsenceBatchPatchMutation(BaseBatchPatchMutation):
 class AbsenceReasonBatchCreateMutation(BaseBatchCreateMutation):
     class Meta:
         model = AbsenceReason
-        fields = ("short_name", "name", "colour", "default")
+        fields = ("short_name", "name", "colour", "default", "tags")
         optional_fields = ("name",)
         permissions = ("kolego.create_absencereason_rule",)
 
+    @classmethod
+    def get_all_objs(cls, Model, ids: Iterable[Union[str, int]]):
+        return list(
+            Model.objects.managed_and_unmanaged().filter(
+                pk__in=[cls.resolve_id(id_) for id_ in ids]
+            )
+        )
+
 
 class AbsenceReasonBatchDeleteMutation(BaseBatchDeleteMutation):
     class Meta:
@@ -143,5 +169,34 @@ class AbsenceReasonBatchDeleteMutation(BaseBatchDeleteMutation):
 class AbsenceReasonBatchPatchMutation(BaseBatchPatchMutation):
     class Meta:
         model = AbsenceReason
-        fields = ("id", "short_name", "name", "colour", "default")
+        fields = ("id", "short_name", "name", "colour", "default", "tags")
         permissions = ("kolego.edit_absencereason_rule",)
+
+    @classmethod
+    def get_all_objs(cls, Model, ids: Iterable[Union[str, int]]):
+        return list(
+            Model.objects.managed_and_unmanaged().filter(
+                pk__in=[cls.resolve_id(id_) for id_ in ids]
+            )
+        )
+
+
+class AbsenceReasonTagBatchCreateMutation(BaseBatchCreateMutation):
+    class Meta:
+        model = AbsenceReasonTag
+        fields = ("short_name", "name")
+        optional_fields = ("name",)
+        permissions = ("kolego.create_absencereasontag_rule",)
+
+
+class AbsenceReasonTagBatchDeleteMutation(BaseBatchDeleteMutation):
+    class Meta:
+        model = AbsenceReasonTag
+        permissions = ("kolego.delete_absencereasontag_rule",)
+
+
+class AbsenceReasonTagBatchPatchMutation(BaseBatchPatchMutation):
+    class Meta:
+        model = AbsenceReasonTag
+        fields = ("id", "short_name", "name")
+        permissions = ("kolego.edit_absencereasontag_rule",)