diff --git a/aleksis/core/frontend/app/apollo.js b/aleksis/core/frontend/app/apollo.js
index 4652c386e9b02f6cc2d8a9f27a6b78f3b8a41f48..97196fc79459ebdbf29da502d0cce2e9fd66da4e 100644
--- a/aleksis/core/frontend/app/apollo.js
+++ b/aleksis/core/frontend/app/apollo.js
@@ -6,6 +6,7 @@ import { ApolloClient, from, HttpLink } from "@/apollo-boost";
 
 import { persistCache, LocalStorageWrapper } from "@/apollo3-cache-persist";
 import { InMemoryCache } from "@/apollo-cache-inmemory";
+import createUploadLink from "@/apollo-upload-client/createUploadLink.mjs";
 
 import errorCodes from "../errorCodes";
 
@@ -29,18 +30,12 @@ function getGraphqlURL() {
   return new URL(settings.urls.graphql, base);
 }
 
-// Define Apollo links for handling query operations.
-const links = [
-  // HTTP link to the real backend (Django)
-  new HttpLink({
-    uri: getGraphqlURL(),
-  }),
-];
-
 /** Upstream Apollo GraphQL client */
 const apolloClient = new ApolloClient({
   cache,
-  link: from(links),
+  link: createUploadLink({
+    uri: getGraphqlURL(),
+  }),
 });
 
 const apolloOpts = {
diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py
index 6d0b519e9f5c2252e5e10ec21f53d4b6a6481cd5..ac94e25565446c047a41b1b6790618d0474f07cc 100644
--- a/aleksis/core/schema/person.py
+++ b/aleksis/core/schema/person.py
@@ -298,6 +298,8 @@ class PersonBatchCreateMutation(BaseBatchCreateMutation):
             "avatar",
             "guardians",
             "primary_group",
+            "photo",
+            "avatar",
         )
         optional_fields = (
             "additional_name",
@@ -317,6 +319,8 @@ class PersonBatchCreateMutation(BaseBatchCreateMutation):
             "avatar",
             "guardians",
             "primary_group",
+            "photo",
+            "avatar",
         )
 
 
@@ -346,23 +350,27 @@ class PersonBatchPatchMutation(BaseBatchPatchMutation):
             "avatar",
             "guardians",
             "primary_group",
+            "photo",
+            "avatar",
         )
         optional_fields = (
-                    "additional_name",
-                    "short_name",
-                    "street",
-                    "housenumber",
-                    "postal_code",
-                    "place",
-                    "phone_number",
-                    "mobile_number",
-                    "email",
-                    "date_of_birth",
-                    "place_of_birth",
-                    "sex",
-                    "description",
-                    "photo",
-                    "avatar",
-                    "guardians",
-                    "primary_group",
-                )
+            "additional_name",
+            "short_name",
+            "street",
+            "housenumber",
+            "postal_code",
+            "place",
+            "phone_number",
+            "mobile_number",
+            "email",
+            "date_of_birth",
+            "place_of_birth",
+            "sex",
+            "description",
+            "photo",
+            "avatar",
+            "guardians",
+            "primary_group",
+            "photo",
+            "avatar",
+        )
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 1915cb4e7048a7e1644009fc5fe8435bc7e33ab8..6fdb6de794a30234cfe6817e53d45f30b4f61bad 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -586,8 +586,9 @@ YARN_INSTALLED_APPS = [
     "@iconify/iconify@^2.2.1",
     "@iconify/json@^2.1.30",
     "@mdi/font@^7.2.96",
+    "@apollo/client@^3.8.0",
     "apollo-boost@^0.4.9",
-    "apollo3-cache-persist@^0.14.1",
+    "apollo3-cache-persist@^0.15.0",
     "deepmerge@^4.2.2",
     "graphql@^15.8.0",
     "graphql-tag@^2.12.6",
diff --git a/aleksis/core/tests/schema/test_persons.py b/aleksis/core/tests/schema/test_persons.py
index 06f7de81c7127dd9d0817d1e6e02dcc7107a0472..420d9882f367a9f9b796c136dc5d60a83da53d8c 100644
--- a/aleksis/core/tests/schema/test_persons.py
+++ b/aleksis/core/tests/schema/test_persons.py
@@ -90,3 +90,136 @@ def test_persons_query(client_query):
         variables={"id": wrong_member.id},
     )
     assert content["data"]["object"] is None
+
+
+def test_create_person_with_file_upload(client_query, uploaded_picture):
+    p = Person.objects.first()
+    global_permission = Permission.objects.get(
+        codename="add_person", content_type__app_label="core"
+    )
+    p.user.user_permissions.add(global_permission)
+
+    query = """
+    mutation createPersons($input: [BatchCreatePersonInput]!) {
+      createPersons(input: $input) {
+        items: persons {
+          id
+          firstName
+          lastName
+        }
+      }
+    }
+    """
+    variables = {
+        "input": [
+            {
+                "firstName": "Foo",
+                "lastName": "Bar",
+                "photo": None,
+                "avatar": None,
+            },
+        ]
+    }
+    files = {
+        "variables.input.0.photo": uploaded_picture,
+        "variables.input.0.avatar": uploaded_picture,
+    }
+
+    response, content = client_query(query, variables=variables, files=files)
+    person_id = content["data"]["createPersons"]["items"][0]["id"]
+
+    created_person = Person.objects.get(id=person_id)
+    assert created_person.first_name == "Foo"
+    assert created_person.last_name == "Bar"
+    assert created_person.photo.file.name.endswith(".jpg")
+    assert created_person.avatar.file.name.endswith(".jpg")
+
+
+def test_edit_person_with_file_upload(client_query, uploaded_picture):
+    p = Person.objects.first()
+    global_permission = Permission.objects.get(
+        codename="change_person", content_type__app_label="core"
+    )
+    p.user.user_permissions.add(global_permission)
+
+    p = Person.objects.create(
+        first_name="Foo",
+        last_name="Bar",
+    )
+
+    query = """
+    mutation updatePersons($input: [BatchPatchPersonInput]!) {
+      updatePersons(input: $input) {
+        items: persons {
+          id
+          firstName
+          lastName
+        }
+      }
+    }
+    """
+
+    # Edit with adding files
+    variables = {
+        "input": [
+            {
+                "id": p.id,
+                "firstName": "Foo",
+                "lastName": "Bar",
+                "photo": None,
+                "avatar": None,
+            },
+        ]
+    }
+    files = {
+        "variables.input.0.photo": uploaded_picture,
+        "variables.input.0.avatar": uploaded_picture,
+    }
+
+    response, content = client_query(query, variables=variables, files=files)
+
+    p.refresh_from_db()
+
+    assert p.first_name == "Foo"
+    assert p.last_name == "Bar"
+    assert p.photo.file.name.endswith(".jpg")
+    assert p.avatar.file.name.endswith(".jpg")
+
+    # Edit without changing files
+    variables = {
+        "input": [
+            {
+                "id": p.id,
+                "firstName": "Foo",
+                "lastName": "Baz",
+            },
+        ]
+    }
+    files = {}
+    response, content = client_query(query, variables=variables, files=files)
+    p.refresh_from_db()
+
+    assert p.first_name == "Foo"
+    assert p.last_name == "Baz"
+    assert p.photo.file.name.endswith(".jpg")
+    assert p.avatar.file.name.endswith(".jpg")
+
+    # Edit with deleting files
+    variables = {
+        "input": [
+            {
+                "id": p.id,
+                "firstName": "Foo",
+                "lastName": "Baz",
+                "photo": None,
+                "avatar": None,
+            },
+        ]
+    }
+    response, content = client_query(query, variables=variables, files=files)
+    p.refresh_from_db()
+
+    assert p.first_name == "Foo"
+    assert p.last_name == "Baz"
+    assert not p.photo
+    assert not p.avatar
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 71147f1c4586280bb630a9e45ebdcaebe3e6aa81..e9c908c1395a208d6c0471898d4ee758ea4608a8 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -45,7 +45,7 @@ from django_celery_results.models import TaskResult
 from django_filters.views import FilterView
 from django_tables2 import SingleTableMixin, SingleTableView
 from dynamic_preferences.forms import preference_form_builder
-from graphene_django.views import GraphQLView
+from graphene_file_upload.django import FileUploadGraphQLView
 from graphql import GraphQLError
 from guardian.shortcuts import GroupObjectPermission, UserObjectPermission
 from haystack.generic_views import SearchView
@@ -1262,7 +1262,7 @@ class TwoFactorLoginView(two_factor_views.LoginView):
         return other_devices
 
 
-class LoggingGraphQLView(GraphQLView):
+class LoggingGraphQLView(FileUploadGraphQLView):
     """GraphQL view that raises unknown exceptions instead of blindly catching them."""
 
     def execute_graphql_request(self, *args, **kwargs):
diff --git a/conftest.py b/conftest.py
index 1a448a6f93b2cc6ae536c916c8a9ab56d5b9fe5a..2c92d6b9540548ea2c6251381fef809cc1413679 100644
--- a/conftest.py
+++ b/conftest.py
@@ -1,6 +1,9 @@
 import json
+from io import BytesIO
 
+import PIL
 import pytest
+from django.core.files.uploadedfile import InMemoryUploadedFile
 from graphene_django.settings import graphene_settings
 
 pytest_plugins = ("celery.contrib.pytest",)
@@ -42,6 +45,52 @@ def graphql_query(
     return resp, content
 
 
+def graphql_query_multipart(
+    query,
+    operation_name=None,
+    input_data=None,
+    variables=None,
+    headers=None,
+    files=None,
+    client=None,
+):
+    """Do a GraphQL query for testing."""
+    graphql_url = graphene_settings.TESTING_ENDPOINT
+    operations = {"query": query}
+    if operation_name:
+        operations["operationName"] = operation_name
+    if variables:
+        operations["variables"] = variables
+    if input_data:
+        if "variables" in operations:
+            operations["variables"]["input"] = input_data
+        else:
+            operations["variables"] = {"input": input_data}
+    body = {
+        "operations": json.dumps(operations),
+    }
+    map = {}
+    if files:
+        for idx, (key, value) in enumerate(files.items()):
+            file_key = f"file_{idx}"
+            body[file_key] = value
+            map[file_key] = [key]
+    body["map"] = json.dumps(map)
+    if headers:
+        header_params = {"headers": headers}
+        resp = client.post(
+            graphql_url,
+            data = body,
+            **header_params,
+        )
+    else:
+        resp = client.post(
+            graphql_url, data=body
+        )
+    content = json.loads(resp.content)
+    return resp, content
+
+
 @pytest.fixture
 def logged_in_client(client, django_user_model):
     """Provide a logged-in client for testing."""
@@ -61,6 +110,17 @@ def logged_in_client(client, django_user_model):
 def client_query(logged_in_client):
     """Do a GraphQL query with a logged-in client."""
     def func(*args, **kwargs):
-        return graphql_query(*args, **kwargs, client=logged_in_client)
+        return graphql_query_multipart(*args, **kwargs, client=logged_in_client)
 
     return func
+
+@pytest.fixture
+def picture():
+    buf = BytesIO()
+    im = PIL.Image.new(mode="RGB", size=(200, 200))
+    im.save(buf, format="JPEG")
+    return buf
+
+@pytest.fixture
+def uploaded_picture(picture):
+    return InMemoryUploadedFile(picture, None, 'test.jpg', 'image/jpeg', None, None)
diff --git a/pyproject.toml b/pyproject.toml
index 5fcd4105aa59a8610abf41a2b03a203e31a27d89..b95e0214858e5ae5ba4dd28304e238b18ff4774d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -132,7 +132,12 @@ uwsgi = "^2.0.21"
 tqdm = "^4.66.1"
 django-pg-rrule = "^0.3.1"
 libsass = "^0.23.0"
+<<<<<<< HEAD
 graphene-django-optimizer-reloaded = "^0.9.2"
+=======
+graphene-django-optimizer = { git = "https://github.com/bellini666/graphene-django-optimizer", rev = "3648b66" }
+graphene-file-upload = "^1.3.0"
+>>>>>>> d178faab (Support file uploads via GraphQL)
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]