diff --git a/aleksis/core/admin.py b/aleksis/core/admin.py
index e35910f597983ea8cfaa957cd475e607c490dfa0..ae51f4bcfddec26f5b31af1fb79628ac6812476f 100644
--- a/aleksis/core/admin.py
+++ b/aleksis/core/admin.py
@@ -10,6 +10,7 @@ from .models import (
     Announcement,
     AnnouncementRecipient,
     CustomMenuItem,
+    DataCheckResult,
     Group,
     Notification,
     Person,
@@ -33,3 +34,4 @@ class AnnouncementAdmin(BaseModelAdmin, VersionAdmin):
 
 
 admin.site.register(Announcement, AnnouncementAdmin)
+admin.site.register(DataCheckResult)
diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py
index e88d812bde928fcdc2f38027b8323aa8f09ad07b..75feb14c3ed9e6d717d1bbd89bf0ef87070d34b3 100644
--- a/aleksis/core/apps.py
+++ b/aleksis/core/apps.py
@@ -1,12 +1,14 @@
 from typing import Any, List, Optional, Tuple
 
 import django.apps
+from django.apps import apps
 from django.conf import settings
 from django.db import OperationalError, ProgrammingError
 from django.http import HttpRequest
 from django.utils.module_loading import autodiscover_modules
 
 from dynamic_preferences.registries import preference_models
+from health_check.plugins import plugin_dir
 
 from .registries import (
     group_preferences_registry,
@@ -52,6 +54,22 @@ class CoreConfig(AppConfig):
 
         self._refresh_authentication_backends()
 
+        self._load_data_checks()
+
+        from .health_checks import DataChecksHealthCheckBackend
+
+        plugin_dir.register(DataChecksHealthCheckBackend)
+
+    @classmethod
+    def _load_data_checks(cls):
+        """Get all data checks from all loaded models."""
+        from aleksis.core.data_checks import DataCheckRegistry
+
+        data_checks = []
+        for model in apps.get_models():
+            data_checks += getattr(model, "data_checks", [])
+        DataCheckRegistry.data_checks = data_checks
+
     @classmethod
     def _refresh_authentication_backends(cls):
         """Refresh config list of enabled authentication backends."""
diff --git a/aleksis/core/data_checks.py b/aleksis/core/data_checks.py
new file mode 100644
index 0000000000000000000000000000000000000000..cb15f66713dee2e66cd4b561fa360ca8fed8641b
--- /dev/null
+++ b/aleksis/core/data_checks.py
@@ -0,0 +1,257 @@
+import logging
+
+from django.contrib.contenttypes.models import ContentType
+from django.db.models.aggregates import Count
+from django.utils.decorators import classproperty
+from django.utils.translation import gettext as _
+
+import reversion
+from reversion import set_comment
+from templated_email import send_templated_mail
+
+from .util.core_helpers import celery_optional, get_site_preferences
+
+
+class SolveOption:
+    """Define a solve option for one or more data checks.
+
+    Solve options are used in order to give the data admin typical
+    solutions to a data issue detected by a data check.
+
+    Example definition
+
+    .. code-block:: python
+
+        from aleksis.core.data_checks import SolveOption
+        from django.utils.translation import gettext as _
+
+        class DeleteSolveOption(SolveOption):
+            name = "delete" # has to be unqiue
+            verbose_name = _("Delete") # should make use of i18n
+
+            @classmethod
+            def solve(cls, check_result: "DataCheckResult"):
+                check_result.related_object.delete()
+                check_result.delete()
+
+    After the solve option has been successfully executed,
+    the corresponding data check result has to be deleted.
+    """
+
+    name: str = "default"
+    verbose_name: str = ""
+
+    @classmethod
+    def solve(cls, check_result: "DataCheckResult"):
+        pass
+
+
+class IgnoreSolveOption(SolveOption):
+    """Mark the object with data issues as solved."""
+
+    name = "ignore"
+    verbose_name = _("Ignore problem")
+
+    @classmethod
+    def solve(cls, check_result: "DataCheckResult"):
+        """Mark the object as solved without doing anything more."""
+        check_result.solved = True
+        check_result.save()
+
+
+class DataCheck:
+    """Define a data check.
+
+    Data checks should be used to search objects of
+    any type which are broken or need some extra action.
+
+    Defining data checks
+    --------------------
+    Data checks are defined by inheriting from the class DataCheck
+    and registering the inherited class in the data check registry.
+
+    Example:
+
+    ``data_checks.py``
+    ******************
+
+    .. code-block:: python
+
+        from aleksis.core.data_checks import DataCheck, DATA_CHECK_REGISTRY
+        from django.utils.translation import gettext as _
+
+        class ExampleDataCheck(DataCheck):
+            name = "example" # has to be unique
+            verbose_name = _("Ensure that there are no examples.")
+            problem_name = _("There is an example.") # should both make use of i18n
+
+            solve_options = {
+                IgnoreSolveOption.name: IgnoreSolveOption
+            }
+
+            @classmethod
+            def check_data(cls):
+                from example_app.models import ExampleModel
+
+                wrong_examples = ExampleModel.objects.filter(wrong_value=True)
+
+                for example in wrong_examples:
+                    cls.register_result(example)
+
+    ``models.py``
+    *************
+
+    .. code-block:: python
+
+        from .data_checks import ExampleDataCheck
+
+        # ...
+
+        class ExampleModel(Model):
+            data_checks = [ExampleDataCheck]
+
+
+    Solve options are used in order to give the data admin typical solutions to this specific issue.
+    They are defined by inheriting from SolveOption.
+    More information about defining solve options can be find there.
+
+    The dictionary ``solve_options`` should include at least the IgnoreSolveOption,
+    but preferably also own solve options. The keys in this dictionary
+    have to be ``<YourOption>SolveOption.name``
+    and the values must be the corresponding solve option classes.
+
+    The class method ``check_data`` does the actual work. In this method
+    your code should find all objects with issues and should register
+    them in the result database using the class method ``register_result``.
+
+    Data checks have to be registered in their corresponding model.
+    This can be done by adding a list ``data_checks``
+    containing the data check classes.
+
+    Executing data checks
+    ---------------------
+    The data checks can be executed by using the
+    celery task named ``aleksis.core.data_checks.check_data``.
+    We recommend to create a periodic task in the backend
+    which executes ``check_data`` on a regular base (e. g. every day).
+
+    .. warning::
+        To use the option described above, you must have setup celery properly.
+
+    Notifications about results
+    ---------------------------
+    The data check tasks can notify persons via email
+    if there are new data issues. You can set these persons
+    by adding them to the preference
+    ``Email recipients for data checks problem emails`` in the site configuration.
+
+    To enable this feature, you also have to activate
+    the preference ``Send emails if data checks detect problems``.
+    """  # noqa: D412
+
+    name: str = ""
+    verbose_name: str = ""
+    problem_name: str = ""
+
+    solve_options = {IgnoreSolveOption.name: IgnoreSolveOption}
+
+    @classmethod
+    def check_data(cls):
+        """Find all objects with data issues and register them."""
+        pass
+
+    @classmethod
+    def solve(cls, check_result: "DataCheckResult", solve_option: str):
+        """Execute a solve option for an object detected by this check.
+
+        :param check_result: The result item from database
+        :param solve_option: The name of the solve option that should be executed
+        """
+        with reversion.create_revision():
+            solve_option_obj = cls.solve_options[solve_option]
+            set_comment(
+                _(
+                    f"Solve option '{solve_option_obj.verbose_name}' "
+                    f"for data check '{cls.verbose_name}'"
+                )
+            )
+            solve_option_obj.solve(check_result)
+
+    @classmethod
+    def register_result(cls, instance) -> "DataCheckResult":
+        """Register an object with data issues in the result database.
+
+        :param instance: The affected object
+        :return: The database entry
+        """
+        from aleksis.core.models import DataCheckResult
+
+        ct = ContentType.objects.get_for_model(instance)
+        result = DataCheckResult.objects.get_or_create(
+            check=cls.name, content_type=ct, object_id=instance.id
+        )
+        return result
+
+
+class DataCheckRegistry:
+    """Create central registry for all data checks in AlekSIS."""
+
+    data_checks = []
+
+    @classproperty
+    def data_checks_by_name(cls):
+        return {check.name: check for check in cls.data_checks}
+
+    @classproperty
+    def data_checks_choices(cls):
+        return [(check.name, check.verbose_name) for check in cls.data_checks]
+
+
+@celery_optional
+def check_data():
+    """Execute all registered data checks and send email if activated."""
+    for check in DataCheckRegistry.data_checks:
+        logging.info(f"Run check: {check.verbose_name}")
+        check.check_data()
+
+    if get_site_preferences()["general__data_checks_send_emails"]:
+        send_emails_for_data_checks()
+
+
+def send_emails_for_data_checks():
+    """Notify one or more recipients about new problems with data.
+
+    Recipients can be set in dynamic preferences.
+    """
+    from .models import DataCheckResult  # noqa
+
+    results = DataCheckResult.objects.filter(solved=False, sent=False)
+
+    if results.exists():
+        results_by_check = results.values("check").annotate(count=Count("check"))
+
+        results_with_checks = []
+        for result in results_by_check:
+            results_with_checks.append(
+                (DataCheckRegistry.data_checks_by_name[result["check"]], result["count"])
+            )
+
+        recipient_list = [
+            p.mail_sender
+            for p in get_site_preferences()["general__data_checks_recipients"]
+            if p.email
+        ]
+
+        for group in get_site_preferences()["general__data_checks_recipient_groups"]:
+            recipient_list += [p.mail_sender for p in group.announcement_recipients if p.email]
+
+        send_templated_mail(
+            template_name="data_checks",
+            from_email=get_site_preferences()["mail__address"],
+            recipient_list=recipient_list,
+            context={"results": results_with_checks},
+        )
+
+        logging.info("Sent notification email because of unsent data checks")
+
+        results.update(sent=True)
diff --git a/aleksis/core/health_checks.py b/aleksis/core/health_checks.py
new file mode 100644
index 0000000000000000000000000000000000000000..e3239087a17e5fed6c59441e9c6a219e86deb006
--- /dev/null
+++ b/aleksis/core/health_checks.py
@@ -0,0 +1,18 @@
+from django.utils.translation import gettext as _
+
+from health_check.backends import BaseHealthCheckBackend
+
+from aleksis.core.models import DataCheckResult
+
+
+class DataChecksHealthCheckBackend(BaseHealthCheckBackend):
+    """Checks whether there are unresolved data problems."""
+
+    critical_service = False
+
+    def check_status(self):
+        if DataCheckResult.objects.filter(solved=False).exists():
+            self.add_error(_("There are unresolved data problems."))
+
+    def identifier(self):
+        return self.__class__.__name__
diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py
index 9b1c42e931235333fb8d4a9dca55cd7f977b47de..fc9ba63604dabf57e1c4101fc8d7f5700548cc6c 100644
--- a/aleksis/core/menus.py
+++ b/aleksis/core/menus.py
@@ -142,6 +142,12 @@ MENUS = {
                         ),
                     ],
                 },
+                {
+                    "name": _("Data checks"),
+                    "url": "check_data",
+                    "icon": "done_all",
+                    "validators": ["menu_generator.validators.is_superuser"],
+                },
                 {
                     "name": _("Backend Admin"),
                     "url": "admin:index",
diff --git a/aleksis/core/migrations/0008_data_check_result.py b/aleksis/core/migrations/0008_data_check_result.py
new file mode 100644
index 0000000000000000000000000000000000000000..3f8285e22a6af4109a67d6baddccd05e69b1bd6e
--- /dev/null
+++ b/aleksis/core/migrations/0008_data_check_result.py
@@ -0,0 +1,63 @@
+# Generated by Django 3.1.3 on 2020-11-14 16:11
+
+import django.contrib.sites.managers
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("sites", "0002_alter_domain_unique"),
+        ("contenttypes", "0002_remove_content_type_name"),
+        ("core", "0007_dashboard_widget_order"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="DataCheckResult",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                ("extended_data", models.JSONField(default=dict, editable=False)),
+                (
+                    "check",
+                    models.CharField(
+                        choices=[], max_length=255, verbose_name="Related data check task"
+                    ),
+                ),
+                ("object_id", models.CharField(max_length=255)),
+                ("solved", models.BooleanField(default=False, verbose_name="Issue solved")),
+                ("sent", models.BooleanField(default=False, verbose_name="Notification sent")),
+                (
+                    "content_type",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
+                    ),
+                ),
+                (
+                    "site",
+                    models.ForeignKey(
+                        default=1,
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="sites.site",
+                    ),
+                ),
+            ],
+            options={
+                "permissions": (
+                    ("run_data_checks", "Can run data checks"),
+                    ("solve_data_problem", "Can solve data check problems"),
+                ),
+                "verbose_name": "Data check result",
+                "verbose_name_plural": "Data check results",
+            },
+            managers=[("objects", django.contrib.sites.managers.CurrentSiteManager()),],
+        ),
+    ]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 0f7a80d416cfe1dec30b57f2bf1e070e664a8966..5f5facd33939560c81fd8197271d1fd8ec9fe4ec 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -26,6 +26,8 @@ from model_utils.models import TimeStampedModel
 from phonenumber_field.modelfields import PhoneNumberField
 from polymorphic.models import PolymorphicModel
 
+from aleksis.core.data_checks import DataCheck, DataCheckRegistry
+
 from .managers import (
     CurrentSiteManagerWithoutMigrations,
     GroupManager,
@@ -843,3 +845,38 @@ class GroupPreferenceModel(PerInstancePreferenceModel, PureDjangoModel):
 
     class Meta:
         app_label = "core"
+
+
+class DataCheckResult(ExtensibleModel):
+    """Save the result of a data check for a specific object."""
+
+    check = models.CharField(
+        max_length=255,
+        verbose_name=_("Related data check task"),
+        choices=DataCheckRegistry.data_checks_choices,
+    )
+
+    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+    object_id = models.CharField(max_length=255)
+    related_object = GenericForeignKey("content_type", "object_id")
+
+    solved = models.BooleanField(default=False, verbose_name=_("Issue solved"))
+    sent = models.BooleanField(default=False, verbose_name=_("Notification sent"))
+
+    @property
+    def related_check(self) -> DataCheck:
+        return DataCheckRegistry.data_checks_by_name[self.check]
+
+    def solve(self, solve_option: str = "default"):
+        self.related_check.solve(self, solve_option)
+
+    def __str__(self):
+        return f"{self.related_object}: {self.related_check.problem_name}"
+
+    class Meta:
+        verbose_name = _("Data check result")
+        verbose_name_plural = _("Data check results")
+        permissions = (
+            ("run_data_checks", _("Can run data checks")),
+            ("solve_data_problem", _("Can solve data check problems")),
+        )
diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py
index eba67c7220314823b0f74efa986cbe09c24d1f43..a8cbe1ef56deee73e82c5d0a77983078de186dac 100644
--- a/aleksis/core/preferences.py
+++ b/aleksis/core/preferences.py
@@ -5,13 +5,15 @@ from django.utils.translation import gettext_lazy as _
 
 from dynamic_preferences.preferences import Section
 from dynamic_preferences.types import (
+    BooleanPreference,
     ChoicePreference,
     FilePreference,
+    ModelMultipleChoicePreference,
     MultipleChoicePreference,
     StringPreference,
 )
 
-from .models import Person
+from .models import Group, Person
 from .registries import person_preferences_registry, site_preferences_registry
 from .util.notifications import get_notification_choices_lazy
 
@@ -209,3 +211,35 @@ class AvailableLanguages(MultipleChoicePreference):
     verbose_name = _("Available languages")
     field_attribute = {"initial": []}
     choices = settings.LANGUAGES
+
+
+@site_preferences_registry.register
+class DataChecksSendEmails(BooleanPreference):
+    """Enable email sending if data checks detect problems."""
+
+    section = general
+    name = "data_checks_send_emails"
+    default = False
+    verbose_name = _("Send emails if data checks detect problems")
+
+
+@site_preferences_registry.register
+class DataChecksEmailsRecipients(ModelMultipleChoicePreference):
+    """Email recipients for data check problem emails."""
+
+    section = general
+    name = "data_checks_recipients"
+    default = []
+    model = Person
+    verbose_name = _("Email recipients for data checks problem emails")
+
+
+@site_preferences_registry.register
+class DataChecksEmailsRecipientGroups(ModelMultipleChoicePreference):
+    """Email recipient groups for data check problem emails."""
+
+    section = general
+    name = "data_checks_recipient_groups"
+    default = []
+    model = Group
+    verbose_name = _("Email recipient groups for data checks problem emails")
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index 2e36bc08bf3a307c0b8a116a5731f0f4793f2475..8ad48fbf18db6cac37270680792aa784a49eb77c 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -279,6 +279,21 @@ view_group_stats_predicate = has_person & (
 )
 rules.add_perm("core.view_group_stats", view_group_stats_predicate)
 
+# View data check results
+view_data_check_results_predicate = has_person & has_global_perm("core.view_datacheckresult")
+rules.add_perm("core.view_datacheckresults", view_data_check_results_predicate)
+
+# Run data checks
+run_data_checks_predicate = (
+    has_person & view_data_check_results_predicate & has_global_perm("core.run_data_checks")
+)
+rules.add_perm("core.run_data_checks", run_data_checks_predicate)
+
+# Solve data problems
+solve_data_problem_predicate = (
+    has_person & view_data_check_results_predicate & has_global_perm("core.solve_data_problem")
+)
+rules.add_perm("core.solve_data_problem", solve_data_problem_predicate)
 
 view_dashboard_widget_predicate = has_person & has_global_perm("core.view_dashboardwidget")
 rules.add_perm("core.view_dashboardwidget", view_dashboard_widget_predicate)
diff --git a/aleksis/core/static/style.scss b/aleksis/core/static/style.scss
index 81f443f75ab79c34121390bf03da9ab1e611dd5b..a7b29299653c8e40d7b98304dcf2ea6f68ee4de2 100644
--- a/aleksis/core/static/style.scss
+++ b/aleksis/core/static/style.scss
@@ -543,6 +543,10 @@ main .alert p:first-child, main .alert div:first-child {
   height: 100%;
 }
 
+.btn-margin {
+  margin-bottom: 5px;
+}
+
 
 /* Dashboard */
 
diff --git a/aleksis/core/templates/core/data_check/list.html b/aleksis/core/templates/core/data_check/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..5a510fdd92f550ad1b7ff6b0b81983ea225420ba
--- /dev/null
+++ b/aleksis/core/templates/core/data_check/list.html
@@ -0,0 +1,103 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+{% load data_helpers %}
+
+{% load i18n %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Data checks{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Data checks{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <a class="btn green waves-effect waves-light" href="{% url "data_check_run" %}">
+    <i class="material-icons left">refresh</i>
+    {% trans "Check data again" %}
+  </a>
+
+  {% if results %}
+    <div class="card">
+      <div class="card-content">
+        <i class="material-icons left medium red-text">warning</i>
+        <span class="card-title">{% trans "The system detected some problems with your data." %}</span>
+        <p>{% blocktrans %}Please go through all data and check whether some extra action is
+          needed.{% endblocktrans %}</p>
+      </div>
+    </div>
+  {% else %}
+    <div class="card">
+      <div class="card-content">
+        <i class="material-icons left medium green-text">check_circle</i>
+        <span class="card-title">{% trans "Everything is fine." %}</span>
+        <p>{% blocktrans %}The system hasn't detected any problems with your data.{% endblocktrans %}</p>
+      </div>
+    </div>
+  {% endif %}
+
+  {% if results %}
+    <div class="card">
+      <div class="card-content">
+        <div class="card-title">{% trans "Detected problems" %}</div>
+        <table>
+          <thead>
+          <tr>
+            <th></th>
+            <th colspan="2">{% trans "Affected object" %}</th>
+            <th>{% trans "Detected problem" %}</th>
+            <th>{% trans "Show details" %}</th>
+            <th>{% trans "Options to solve the problem" %}</th>
+          </tr>
+          </thead>
+          <tbody>
+          {% for result in results %}
+            <tr>
+              <td>
+                <code>{{ result.id }}</code>
+              </td>
+              <td>{% verbose_name_object result.related_object %}</td>
+              <td>{{ result.related_object }}</td>
+              <td>{{ result.related_check.problem_name }}</td>
+              <td>
+                <a class="btn-flat waves-effect waves-light" href="{{ result.related_object.get_absolute_url }}">
+                  {% trans "Show object" %}
+                </a>
+              </td>
+              <td>
+                {% for option_name, option in result.related_check.solve_options.items %}
+                  <a class="btn btn-margin waves-effect waves-light"
+                     href="{% url "data_check_solve" result.pk option_name %}">
+                    {{ option.verbose_name }}
+                  </a>
+                {% endfor %}
+              </td>
+            </tr>
+          {% endfor %}
+          </tbody>
+        </table>
+      </div>
+    </div>
+  {% endif %}
+  </div>
+
+  <div class="card hundred-percent">
+    <div class="card-content">
+      <div class="card-title">{% trans "Registered checks" %}</div>
+      <div class="alert primary">
+        <div>
+          <i class="material-icons left">info</i>
+          {% blocktrans %}
+            The system will check for the following problems:
+          {% endblocktrans %}
+        </div>
+      </div>
+      <ul class="collection">
+        {% for check in registered_checks %}
+          <li class="collection-item">
+            <i class="material-icons left">check</i>
+            {{ check.verbose_name }}
+          </li>
+        {% endfor %}
+      </ul>
+    </div>
+  </div>
+{% endblock %}
diff --git a/aleksis/core/templates/templated_email/data_checks.css b/aleksis/core/templates/templated_email/data_checks.css
new file mode 100644
index 0000000000000000000000000000000000000000..b385b185ecfe66dcca070e8eb024bbb091917f04
--- /dev/null
+++ b/aleksis/core/templates/templated_email/data_checks.css
@@ -0,0 +1,16 @@
+body {
+    line-height: 1.5;
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+    font-weight: normal;
+    color: rgba(0, 0, 0, 0.87);
+}
+
+.count {
+    text-align: right;
+    font-family: monospace;
+    font-size: 14pt;
+}
+
+td, th {
+    padding: 10px;
+}
diff --git a/aleksis/core/templates/templated_email/data_checks.email b/aleksis/core/templates/templated_email/data_checks.email
new file mode 100644
index 0000000000000000000000000000000000000000..0f9b5a5307fba9152683d4e03b0c41a2105629c7
--- /dev/null
+++ b/aleksis/core/templates/templated_email/data_checks.email
@@ -0,0 +1,44 @@
+{% load i18n %}
+
+{% block subject %}
+ {% trans "The system detected some new problems with your data." %}
+{% endblock %}
+
+{% block plain %}
+ {% trans "Hello," %}
+
+ {% blocktrans %}
+  the system detected some new problems with your data.
+  Please take some time to inspect them and solve the issues or mark them as ignored.
+ {% endblocktrans %}
+
+ {% for result in results %}
+  {{ result.0.problem_name }}: {{ result.1 }}
+ {% endfor %}
+{% endblock %}
+
+{% block html %}
+ <style>
+  {% include "templated_email/data_checks.css" %}
+ </style>
+ <p>{% trans "Hello," %}</p>
+ <p>
+  {% blocktrans %}
+   the system detected some new problems with your data.
+   Please take some time to inspect them and solve the issues or mark them as ignored.
+  {% endblocktrans %}
+ </p>
+
+ <table>
+  <tr>
+   <th>{% trans "Problem description" %}</th>
+   <th>{% trans "Count of objects with new problems" %}</th>
+  </tr>
+  {% for result in results %}
+   <tr>
+    <td>{{ result.0.problem_name }}</td>
+    <td class="count">{{ result.1 }}</td>
+   </tr>
+  {% endfor %}
+ </table>
+{% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 7f0f81b0960174c8df67fb862a38e4d2bdd38164..930b3fbc8e8bec19bd32cadb6a6fd6bd7833f0f7 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -154,6 +154,13 @@ urlpatterns = [
         name="preferences_group",
     ),
     path("health/", include(health_urls)),
+    path("data_check/", views.DataCheckView.as_view(), name="check_data",),
+    path("data_check/run/", views.RunDataChecks.as_view(), name="data_check_run",),
+    path(
+        "data_check/<int:pk>/<str:solve_option>/",
+        views.SolveDataCheckView.as_view(),
+        name="data_check_solve",
+    ),
     path("dashboard_widgets/", views.DashboardWidgetListView.as_view(), name="dashboard_widgets"),
     path(
         "dashboard_widgets/<int:pk>/edit/",
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index bef457feee2b0dd53f72591523387d912c4038b1..fcbf1b65f7b20aea5c2bf1d7b5098c922c12dd1f 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -195,6 +195,12 @@ def celery_optional(orig: Callable) -> Callable:
     If Celery is configured and available, it wraps the function in a Task
     and calls its delay method when invoked; if not, it leaves it untouched
     and it is executed synchronously.
+
+    The wrapped function returns a tuple with either
+    the return value of the task's delay method and False
+    if the method has been executed asynchronously
+    or the return value of the executed method and True
+    if the method has been executed synchronously.
     """
     if is_celery_enabled():
         from ..celery import app  # noqa
@@ -203,9 +209,9 @@ def celery_optional(orig: Callable) -> Callable:
 
     def wrapped(*args, **kwargs):
         if is_celery_enabled():
-            task.delay(*args, **kwargs)
+            return task.delay(*args, **kwargs), False
         else:
-            orig(*args, **kwargs)
+            return orig(*args, **kwargs), True
 
     return wrapped
 
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 14cbf2dbeafc7dd84edfadcd5375e97560159011..016b49052f9272bd3743c78eb86689a0395194bd 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -5,6 +5,7 @@ from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import PermissionDenied
 from django.core.paginator import Paginator
+from django.db.models import QuerySet
 from django.forms.models import BaseModelForm, modelform_factory
 from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
 from django.shortcuts import get_object_or_404, redirect, render
@@ -13,6 +14,8 @@ from django.utils.decorators import method_decorator
 from django.utils.translation import gettext_lazy as _
 from django.views.decorators.cache import never_cache
 from django.views.generic.base import View
+from django.views.generic.detail import DetailView
+from django.views.generic.list import ListView
 
 import reversion
 from django_tables2 import RequestConfig, SingleTableView
@@ -23,8 +26,11 @@ from haystack.query import SearchQuerySet
 from haystack.views import SearchView
 from health_check.views import MainView
 from reversion import set_user
+from reversion.views import RevisionMixin
 from rules.contrib.views import PermissionRequiredMixin, permission_required
 
+from aleksis.core.data_checks import DataCheckRegistry, check_data
+
 from .filters import GroupFilter, PersonFilter
 from .forms import (
     AnnouncementForm,
@@ -46,6 +52,7 @@ from .models import (
     Announcement,
     DashboardWidget,
     DashboardWidgetOrder,
+    DataCheckResult,
     Group,
     GroupType,
     Notification,
@@ -702,6 +709,62 @@ def delete_group_type(request: HttpRequest, id_: int) -> HttpResponse:
     return redirect("group_types")
 
 
+class DataCheckView(PermissionRequiredMixin, ListView):
+    permission_required = "core.view_datacheckresults"
+    model = DataCheckResult
+    template_name = "core/data_check/list.html"
+    context_object_name = "results"
+
+    def get_queryset(self) -> QuerySet:
+        return DataCheckResult.objects.filter(solved=False).order_by("check")
+
+    def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+        context = super().get_context_data(**kwargs)
+        context["registered_checks"] = DataCheckRegistry.data_checks
+        return context
+
+
+class RunDataChecks(PermissionRequiredMixin, View):
+    permission_required = "core.run_data_checks"
+
+    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+        if not check_data()[1]:
+            messages.success(
+                request,
+                _(
+                    "The data check has been started. Please note that it may take "
+                    "a while before you are able to fetch the data on this page."
+                ),
+            )
+        else:
+            messages.success(request, _("The data check has finished."))
+        return redirect("check_data")
+
+
+class SolveDataCheckView(PermissionRequiredMixin, RevisionMixin, DetailView):
+    queryset = DataCheckResult.objects.all()
+    permission_required = "core.solve_data_problem"
+
+    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+        solve_option = self.kwargs["solve_option"]
+        result = self.get_object()
+        if solve_option in result.related_check.solve_options:
+            solve_option_obj = result.related_check.solve_options[solve_option]
+
+            msg = _(
+                f"The solve option '{solve_option_obj.verbose_name}' "
+                f"has been executed on the object '{result.related_object}' "
+                f"(type: {result.related_object._meta.verbose_name})."
+            )
+
+            result.solve(solve_option)
+
+            messages.success(request, msg)
+            return redirect("check_data")
+        else:
+            return HttpResponseNotFound()
+
+
 class DashboardWidgetListView(SingleTableView, PermissionRequiredMixin):
     """Table of all dashboard widgets."""