diff --git docs/TODO docs/TODO
index 37c30fd951dd..693e4bb743be 100644
--- docs/TODO
+++ docs/TODO
@@ -7,10 +7,4 @@
 * store rejected mails
 * In-message From: header
 * Per-user default filter settings
-* pwclient: -b <bundle> to specify multiple patch ids
-* pwclient: add bundle manipulation and retrieval ops
-* pwclient: specify multiple patches by ID range
-* pwclient: integrate hash parser
-* pwclient: add example scripts (eg, git post-commit hook) to online help
-* pwclient: case-insensitive searches for author and project name
 * changing primary email addresses for accounts
diff --git docs/api/index.rst docs/api/index.rst
index c679dae133bf..6e89b7197e39 100644
--- docs/api/index.rst
+++ docs/api/index.rst
@@ -3,12 +3,9 @@
 API Documentation
 =================
 
-Patchwork provides two APIs: a REST API and a legacy XML-RPC API. The REST API
-is recommended as the XML-RPC API is deprecated and will be removed in a future
-release.
+Patchwork provides a REST API.
 
 .. toctree::
    :maxdepth: 2
 
    /api/rest/index
-   /api/xmlrpc
diff --git docs/api/rest/index.rst docs/api/rest/index.rst
index d1169e56e07a..293490b2874f 100644
--- docs/api/rest/index.rst
+++ docs/api/rest/index.rst
@@ -25,8 +25,8 @@ If all you want is reference guides, skip straight to :ref:`rest-api-schemas`.
 .. versionadded:: 2.0
 
    The REST API was introduced in Patchwork v2.0. Users of earlier Patchwork
-   versions should instead refer to :doc:`XML-RPC API </api/xmlrpc>`
-   documentation.
+   versions should instead refer to XML-RPC API documentation provided with
+   those versions.
 
 .. versionchanged:: 2.1
 
diff --git docs/api/xmlrpc.rst docs/api/xmlrpc.rst
deleted file mode 100644
index 5412cce5d155..000000000000
--- docs/api/xmlrpc.rst
+++ /dev/null
@@ -1,64 +0,0 @@
-The XML-RPC API
-===============
-
-Patchwork provides an XML-RPC API. This API can be used to be used to retrieve
-and modify information about patches, projects and more.
-
-.. important::
-
-   The XML-RPC API can be enabled/disabled by the administrator: it may not be
-   available in every instance. Refer to ``/about`` on your given instance for
-   the status of the API, e.g.
-
-       https://patchwork.ozlabs.org/about
-
-   Alternatively, simply attempt to make a request to the API.
-
-.. deprecated:: 2.0
-
-    The XML-RPC API is a legacy API and has been deprecated in favour of the
-    :doc:`REST API <rest/index>`. It will be removed in Patchwork 3.0.
-
-Getting Started
----------------
-
-The Patchwork XML-RPC API provides a number of "methods". Some methods require
-authentication (via HTTP Basic Auth) while others do not. Authentication uses
-your Patchwork account and the on-server documentation will indicate where it
-is necessary. We will only cover the unauthenticated method here for brevity -
-consult the `xmlrpclib`_ documentation for more detailed examples:
-
-To interact with the Patchwork XML-RPC API, a XML-RPC library should be used.
-Python provides such a library - `xmlrpclib`_ - in its standard library. For
-example, to get the version of the XML-RPC API for a Patchwork instance hosted
-at `patchwork.example.com`, run:
-
-.. code-block:: pycon
-
-    $ python
-    >>> import xmlrpclib  # or 'xmlrpc.client' for Python 3
-    >>> rpc = xmlrpclib.ServerProxy('http://patchwork.example.com/xmlrpc/')
-    >>> rpc.pw_rpc_version()
-    1.1
-
-Once connected, the ``rpc`` object will be populated with a list of available
-functions (or procedures, in RPC terminology). In the above example, we used
-the ``pw_rpc_version`` method, however, it should be possible to use all the
-methods listed in the server documentation.
-
-Further Information
--------------------
-
-Patchwork provides automatically generated documentation for the XML-RPC API.
-You can find this at the following URL:
-
-    https://patchwork.example.com/xmlrpc/
-
-where `patchwork.example.com` refers to the URL of your Patchwork instance.
-
-.. versionchanged:: 1.1
-
-   Automatic documentation generation for the Patchwork API was introduced in
-   Patchwork v1.1. Prior versions of Patchwork do not offer this functionality.
-
-.. _xmlrpclib: https://docs.python.org/2/library/xmlrpclib.html
diff --git docs/deployment/configuration.rst docs/deployment/configuration.rst
index a71dd3f4bb57..482712f96773 100644
--- docs/deployment/configuration.rst
+++ docs/deployment/configuration.rst
@@ -80,10 +80,6 @@ Enable the :doc:`REST API <../api/rest/index>`.
 
 .. versionadded:: 2.0
 
-``ENABLE_XMLRPC``
-~~~~~~~~~~~~~~~~~
-
-Enable the :doc:`XML-RPC API <../api/xmlrpc>`.
 
 .. TODO(stephenfin) Deprecate this in favor of SECURE_SSL_REDIRECT
 
diff --git docs/deployment/installation.rst docs/deployment/installation.rst
index f477a110f292..a505796e6381 100644
--- docs/deployment/installation.rst
+++ docs/deployment/installation.rst
@@ -280,15 +280,8 @@ described in :doc:`configuration`.
 * ``NOTIFICATION_FROM_EMAIL``
 
 These are not configurable using environment variables and must be configured
-directly in the ``production.py`` settings file instead. For example, if you
-wish to enable the XML-RPC API, you should add the following:
-
-.. code-block:: python
-
-   ENABLE_XMLRPC = True
-
-Similarly, should you wish to disable the REST API, you should add the
-following:
+directly in the ``production.py`` settings file instead. For example, should
+you wish to disable the REST API, you should add the following:
 
 .. code-block:: python
 
@@ -506,8 +499,7 @@ doing the following:
 
 Once the administrative console is accessible, you would want to configure your
 different sites and their corresponding domain names, which is required for the
-different emails sent by Patchwork (registration, password recovery) as well as
-the sample ``pwclientrc`` files provided by your project's page.
+different emails sent by Patchwork (registration, password recovery).
 
 .. _deployment-parsemail:
 
@@ -646,27 +638,6 @@ You can also create such as service yourself using a PaaS provider that
 supports incoming mail and writing a little web app.
 
 
-.. _deployment-vcs:
-
-(Optional) Configure your VCS to Automatically Update Patches
--------------------------------------------------------------
-
-The ``tools`` directory of the Patchwork distribution contains a file named
-``post-receive.hook`` which is a sample Git hook that can be used to
-automatically update patches to the *Accepted* state when corresponding commits
-are pushed via Git.
-
-To install this hook, simply copy it to the ``.git/hooks`` directory on your
-server, name it ``post-receive``, and make it executable.
-
-This sample hook has support to update patches to different states depending on
-which branch is being pushed to. See the ``STATE_MAP`` setting in that file.
-
-If you are using a system other than Git, you can likely write a similar hook
-using the :doc:`APIs </api/index>` or :doc:`API clients </usage/clients>` to to
-update patch state. If you do write one, please contribute it.
-
-
 .. _deployment-cron:
 
 (Optional) Configure the Patchwork Cron Job
diff --git docs/deployment/management.rst docs/deployment/management.rst
index 9c57f1962283..0881dc296cc9 100644
--- docs/deployment/management.rst
+++ docs/deployment/management.rst
@@ -128,9 +128,9 @@ Update the hashes on existing patches.
    ./manage.py rehash [<patch_id>, ...]
 
 Patchwork stores hashes for each patch it receives. These hashes can be used to
-uniquely identify a patch for things like :ref:`automatically changing the
-state of the patch in Patchwork when it merges <deployment-vcs>`. If you change
-your hashing algorithm, you may wish to rehash the patches.
+uniquely identify a patch for things like automatically changing the state of
+the patch in Patchwork when it is merged. If you change your hashing algorithm,
+you may wish to rehash the patches.
 
 .. option:: patch_id
 
diff --git docs/development/api.rst docs/development/api.rst
index cea7bc78dd55..e5298e5e9d23 100644
--- docs/development/api.rst
+++ docs/development/api.rst
@@ -3,9 +3,8 @@
 Using the APIs
 ==============
 
-Patchwork provides two APIs: the legacy :doc:`XML-RPC API </api/xmlrpc>` and
-the :doc:`REST API </api/rest/index>`. You can use these APIs to interact with
-Patchwork programmatically and to develop your own clients.
+Patchwork provides a :doc:`REST API </api/rest/index>`. You can use this API
+to interact with Patchwork programmatically and to develop your own clients.
 
 For quick usage examples of the APIs, refer to the :doc:`documentation
 <../api/index>`. For examples of existing clients, refer to
diff --git docs/index.rst docs/index.rst
index b73c647c5222..1eff93d83080 100644
--- docs/index.rst
+++ docs/index.rst
@@ -51,7 +51,6 @@ of community projects.
    :caption: API Documentation
 
    api/rest/index
-   api/xmlrpc
 
 .. toctree::
    :maxdepth: 2
diff --git docs/usage/clients.rst docs/usage/clients.rst
index 01dd62a28e50..40b93f27b344 100644
--- docs/usage/clients.rst
+++ docs/usage/clients.rst
@@ -1,31 +1,8 @@
 Clients
 =======
 
-A number of clients are available for interacting with Patchwork's various
-APIs.
-
-
-pwclient
---------
-
-.. versionchanged:: 2.2
-
-   :program:`pwclient` was previously provided with Patchwork. It has been
-   packaged as a separate application since Patchwork v2.2.0.
-
-The :program:`pwclient` application can be used to interact with Patchwork from
-the command line. Functionality provided by :program:`pwclient` includes:
-
-- Listing patches, projects, and checks
-- Downloading and applying patches to a local code base
-- Modifying the status of patches
-- Creating new checks
-
-More information on :program:`pwclient`, including installation and usage
-instructions, can be found in the `documentation`__ and the `GitHub repo`__.
-
-__ https://pwclient.readthedocs.io/
-__ https://github.com/getpatchwork/pwclient/
+A REST client is available for interacting with Patchwork's API, and other
+projects build on top of the API to provide other functionality.
 
 
 git-pw
diff --git patchwork/settings/base.py patchwork/settings/base.py
index b86cdc276d5a..fbb0b0d8565b 100644
--- patchwork/settings/base.py
+++ patchwork/settings/base.py
@@ -211,9 +211,6 @@ NOTIFICATION_DELAY_MINUTES = 10
 
 NOTIFICATION_FROM_EMAIL = DEFAULT_FROM_EMAIL
 
-# Set to True to enable the Patchwork XML-RPC interface
-ENABLE_XMLRPC = False
-
 # Set to True to enable the Patchwork REST API
 ENABLE_REST_API = True
 
diff --git patchwork/settings/dev.py patchwork/settings/dev.py
index e110e74579ca..141915fdd001 100644
--- patchwork/settings/dev.py
+++ patchwork/settings/dev.py
@@ -99,6 +99,4 @@ if dbbackup:
 # Patchwork settings
 #
 
-ENABLE_XMLRPC = True
-
 ENABLE_REST_API = True
diff --git patchwork/templates/patchwork/about.html patchwork/templates/patchwork/about.html
index 210e9513c4f4..9c83ba0e4173 100644
--- patchwork/templates/patchwork/about.html
+++ patchwork/templates/patchwork/about.html
@@ -56,16 +56,6 @@
         <span class="label label-warning pull-right">disabled</span>
         {% endif %}
       </li>
-      <li class="list-group-item">
-        XML-RPC
-        <span class="glyphicon glyphicon-question-sign" title="The XML-RPC
-          API"></span>
-        {% if enabled_apis.xmlrpc %}
-        <span class="label label-success pull-right">enabled</span>
-        {% else %}
-        <span class="label label-warning pull-right">disabled</span>
-        {% endif %}
-      </li>
     </ul>
   </div>
 </div>
diff --git patchwork/templates/patchwork/project.html patchwork/templates/patchwork/project.html
index bd9d20e263d8..ce647b9df04b 100644
--- patchwork/templates/patchwork/project.html
+++ patchwork/templates/patchwork/project.html
@@ -55,19 +55,5 @@
 {% endif %}
 </table>
 
-{% if enable_xmlrpc %}
-<h2>pwclient</h2>
-
-<p><code>pwclient</code> is the command-line client for Patchwork. Currently,
-it provides access to some read-only features of Patchwork, such as downloading
-and applying patches.</p>
-
-<p>To use pwclient, you will need:</p>
-<ul>
- <li>The <a href="https://github.com/getpatchwork/pwclient">pwclient</a> program</li>
- <li>(optional) A <code><a href="{% url 'pwclientrc' project.linkname %}"
- >.pwclientrc</a></code> file for this project, which should be stored in your
- home directory.</li>
-</ul>
-{% endif %}
+<!-- TODO add git pw info -->
 {% endblock %}
diff --git patchwork/templates/patchwork/pwclientrc patchwork/templates/patchwork/pwclientrc
deleted file mode 100644
index 7d466d890da5..000000000000
--- patchwork/templates/patchwork/pwclientrc
+++ /dev/null
@@ -1,15 +0,0 @@
-# Sample .pwclientrc file for the {{ project.linkname }} project,
-# running on {{ site.domain }}.
-#
-# Just append this file to your existing ~/.pwclientrc
-# If you do not already have a ~/.pwclientrc, then copy this file to
-# ~/.pwclientrc, and uncomment the following two lines:
-# [options]
-# default={{ project.linkname }}
-
-[{{ project.linkname }}]
-url = {{ scheme }}://{{ site.domain }}{% url 'xmlrpc' %}
-{% if user.is_authenticated %}
-username = {{ user.username }}
-password = <add your patchwork password here>
-{% endif %}
diff --git patchwork/templates/patchwork/xmlrpc-removed.html patchwork/templates/patchwork/xmlrpc-removed.html
new file mode 100644
index 000000000000..bc2e41ebf313
--- /dev/null
+++ patchwork/templates/patchwork/xmlrpc-removed.html
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+
+{% block title %}XML-RPC API removed{% endblock %}
+{% block heading %}XML-RPC API removed{% endblock %}
+
+{% block body %}
+<div class="container">
+  <h1>XML-RPC API removed</h1>
+
+  <p>The XML-RPC API has been removed in Patchwork 3 in favour of the REST API.</p>
+
+  <p>If you were using pwclient, try
+    <a href="https://github.com/getpatchwork/git-pw" target="_blank">git-pw</a>.
+  </p>
+</div>
+{% endblock %}
diff --git patchwork/tests/test_about.py patchwork/tests/test_about.py
index d8c35b9f3e5f..2d9385d8ea8b 100644
--- patchwork/tests/test_about.py
+++ patchwork/tests/test_about.py
@@ -15,7 +15,6 @@ class AboutViewTest(TestCase):
     def _test_redirect(self, view):
         requested_url = reverse(view)
         redirect_url = reverse('about')
-
         response = self.client.get(requested_url)
         self.assertRedirects(response, redirect_url, 301)
 
@@ -23,21 +22,6 @@ class AboutViewTest(TestCase):
         for view in ['help', 'help-about']:
             self._test_redirect(view)
 
-    @unittest.skipUnless(settings.ENABLE_XMLRPC,
-                         'requires xmlrpc interface (use the ENABLE_XMLRPC '
-                         'setting)')
-    def test_redirects_xmlrpc(self):
-        self._test_redirect('help-pwclient')
-
-    def test_xmlrpc(self):
-        with self.settings(ENABLE_XMLRPC=False):
-            response = self.client.get(reverse('about'))
-            self.assertFalse(response.context['enabled_apis']['xmlrpc'])
-
-        with self.settings(ENABLE_XMLRPC=True):
-            response = self.client.get(reverse('about'))
-            self.assertTrue(response.context['enabled_apis']['xmlrpc'])
-
     def test_rest(self):
         with self.settings(ENABLE_REST_API=False):
             response = self.client.get(reverse('about'))
diff --git patchwork/tests/test_xmlrpc.py patchwork/tests/test_xmlrpc.py
deleted file mode 100644
index 79c6c848a0c2..000000000000
--- patchwork/tests/test_xmlrpc.py
+++ /dev/null
@@ -1,224 +0,0 @@
-# Patchwork - automated patch tracking system
-# Copyright (C) 2014 Jeremy Kerr <jk@ozlabs.org>
-#
-# SPDX-License-Identifier: GPL-2.0-or-later
-
-import unittest
-
-from django.conf import settings
-from django.test import LiveServerTestCase
-from django.urls import reverse
-from django.utils.six.moves import xmlrpc_client
-
-from patchwork.tests import utils
-
-
-@unittest.skipUnless(settings.ENABLE_XMLRPC,
-                     'requires xmlrpc interface (use the ENABLE_XMLRPC '
-                     'setting)')
-class XMLRPCTest(LiveServerTestCase):
-
-    def setUp(self):
-        self.url = self.live_server_url + reverse('xmlrpc')
-        self.rpc = xmlrpc_client.Server(self.url)
-
-
-class XMLRPCGenericTest(XMLRPCTest):
-
-    def test_pw_rpc_version(self):
-        # If you update the RPC version, update the tests!
-        self.assertEqual(self.rpc.pw_rpc_version(), [1, 3, 0])
-
-    def test_get_redirect(self):
-        response = self.client.patch(self.url)
-        self.assertRedirects(response, reverse('project-list'))
-
-    def test_invalid_method(self):
-        with self.assertRaises(xmlrpc_client.Fault):
-            self.rpc.xyzzy()
-
-    def test_absent_auth(self):
-        with self.assertRaises(xmlrpc_client.Fault):
-            self.rpc.patch_set(0, {})
-
-
-@unittest.skipUnless(settings.ENABLE_XMLRPC,
-                     'requires xmlrpc interface (use the ENABLE_XMLRPC '
-                     'setting)')
-class XMLRPCAuthenticatedTest(LiveServerTestCase):
-
-    def setUp(self):
-        self.url = self.live_server_url + reverse('xmlrpc')
-        # url is of the form http://localhost:PORT/PATH
-        # strip the http and replace it with the username/passwd of a user.
-        self.project = utils.create_project()
-        self.user = utils.create_maintainer(self.project)
-        self.url = ('http://%s:%s@' + self.url[7:]) % (self.user.username,
-                                                       self.user.username)
-        self.rpc = xmlrpc_client.Server(self.url)
-
-    def test_patch_set(self):
-        patch = utils.create_patch(project=self.project)
-        result = self.rpc.patch_get(patch.id)
-        self.assertFalse(result['archived'])
-
-        self.rpc.patch_set(patch.id, {'archived': True})
-
-        # reload the patch
-        result = self.rpc.patch_get(patch.id)
-        self.assertTrue(result['archived'])
-
-
-class XMLRPCModelTestMixin(object):
-
-    def create_multiple(self, count):
-        return [self.create_single() for i in range(count)]
-
-    def test_get_none(self):
-        self.assertEqual(self.get_endpoint(0), {})
-
-    def test_list_none(self):
-        self.assertEqual(self.list_endpoint(), [])
-
-    def test_list_single(self):
-        obj = self.create_single()
-        result = self.list_endpoint()
-        self.assertEqual(len(result), 1)
-        self.assertEqual(result[0]['id'], obj.id)
-
-    def test_list_named(self):
-        obj = self.create_single(name='FOOBARBAZ')
-        self.create_multiple(5)
-        result = self.list_endpoint('oobarb')
-        self.assertEqual(len(result), 1)
-        self.assertEqual(result[0]['id'], obj.id)
-
-    def test_list_named_none(self):
-        self.create_multiple(5)
-        result = self.list_endpoint('invisible')
-        self.assertEqual(len(result), 0)
-
-    def test_get_single(self):
-        obj = self.create_single()
-        result = self.get_endpoint(obj.id)
-        self.assertEqual(result['id'], obj.id)
-
-    def test_get_invalid(self):
-        obj = self.create_single()
-        result = self.get_endpoint(obj.id + 1)
-        self.assertEqual(result, {})
-
-    def test_list_multiple(self):
-        self.create_multiple(5)
-        result = self.list_endpoint()
-        self.assertEqual(len(result), 5)
-
-    def test_list_max_count(self):
-        objs = self.create_multiple(5)
-        result = self.list_endpoint("", 2)
-        self.assertEqual(len(result), 2)
-        self.assertEqual(result[0]['id'], objs[0].id)
-
-    def test_list_negative_max_count(self):
-        objs = self.create_multiple(5)
-        result = self.list_endpoint("", -1)
-        self.assertEqual(len(result), 1)
-        self.assertEqual(result[0]['id'], objs[-1].id)
-
-
-class XMLRPCFilterModelTestMixin(XMLRPCModelTestMixin):
-
-    # override these tests due to the way you pass in filters
-    def test_list_max_count(self):
-        objs = self.create_multiple(5)
-        result = self.list_endpoint({'max_count': 2})
-        self.assertEqual(len(result), 2)
-        self.assertEqual(result[0]['id'], objs[0].id)
-
-    def test_list_negative_max_count(self):
-        objs = self.create_multiple(5)
-        result = self.list_endpoint({'max_count': -1})
-        self.assertEqual(len(result), 1)
-        self.assertEqual(result[0]['id'], objs[-1].id)
-
-    def test_list_named(self):
-        obj = self.create_single(name='FOOBARBAZ')
-        self.create_multiple(5)
-        result = self.list_endpoint({'name__icontains': 'oobarb'})
-        self.assertEqual(len(result), 1)
-        self.assertEqual(result[0]['id'], obj.id)
-
-    def test_list_named_none(self):
-        self.create_multiple(5)
-        result = self.list_endpoint({'name__icontains': 'invisible'})
-        self.assertEqual(len(result), 0)
-
-
-class XMLRPCPatchTest(XMLRPCTest, XMLRPCFilterModelTestMixin):
-    def setUp(self):
-        super(XMLRPCPatchTest, self).setUp()
-        self.get_endpoint = self.rpc.patch_get
-        self.list_endpoint = self.rpc.patch_list
-        self.create_multiple = utils.create_patches
-
-    def create_single(self, **kwargs):
-        return utils.create_patches(**kwargs)[0]
-
-    def test_patch_check_get(self):
-        patch = self.create_single()
-        check = utils.create_check(patch=patch)
-        result = self.rpc.patch_check_get(patch.id)
-        self.assertEqual(result['total'], 1)
-        self.assertEqual(result['checks'][0]['id'], check.id)
-        self.assertEqual(result['checks'][0]['patch_id'], patch.id)
-
-    def test_patch_get_by_hash(self):
-        patch = self.create_single()
-        result = self.rpc.patch_get_by_hash(patch.hash)
-        self.assertEqual(result['id'], patch.id)
-
-
-class XMLRPCPersonTest(XMLRPCTest, XMLRPCModelTestMixin):
-
-    def setUp(self):
-        super(XMLRPCPersonTest, self).setUp()
-        self.get_endpoint = self.rpc.person_get
-        self.list_endpoint = self.rpc.person_list
-        self.create_single = utils.create_person
-
-
-class XMLRPCProjectTest(XMLRPCTest, XMLRPCModelTestMixin):
-
-    def setUp(self):
-        super(XMLRPCProjectTest, self).setUp()
-        self.get_endpoint = self.rpc.project_get
-        self.list_endpoint = self.rpc.project_list
-        self.create_single = utils.create_project
-
-    def test_list_named(self):
-        # project filters by linkname, not name!
-        obj = self.create_single(linkname='FOOBARBAZ')
-        result = self.list_endpoint('oobarb')
-        self.assertEqual(len(result), 1)
-        self.assertEqual(result[0]['id'], obj.id)
-
-
-class XMLRPCStateTest(XMLRPCTest, XMLRPCModelTestMixin):
-
-    def setUp(self):
-        super(XMLRPCStateTest, self).setUp()
-        self.get_endpoint = self.rpc.state_get
-        self.list_endpoint = self.rpc.state_list
-        self.create_single = utils.create_state
-
-
-class XMLRPCCheckTest(XMLRPCTest, XMLRPCFilterModelTestMixin):
-
-    def setUp(self):
-        super(XMLRPCCheckTest, self).setUp()
-        self.get_endpoint = self.rpc.check_get
-        self.list_endpoint = self.rpc.check_list
-        self.create_single = utils.create_check
-
-    def test_list_named(self):
-        pass
diff --git patchwork/urls.py patchwork/urls.py
index dcdcfb49e67e..2f2b448da331 100644
--- patchwork/urls.py
+++ patchwork/urls.py
@@ -18,10 +18,9 @@ from patchwork.views import mail as mail_views
 from patchwork.views import notification as notification_views
 from patchwork.views import patch as patch_views
 from patchwork.views import project as project_views
-from patchwork.views import pwclient as pwclient_views
 from patchwork.views import series as series_views
 from patchwork.views import user as user_views
-from patchwork.views import xmlrpc as xmlrpc_views
+from patchwork.views import removed as removed_views
 
 
 admin.autodiscover()
@@ -163,16 +162,6 @@ if 'debug_toolbar' in settings.INSTALLED_APPS:
         url(r'^__debug__/', include(debug_toolbar.urls)),
     ]
 
-if settings.ENABLE_XMLRPC:
-    urlpatterns += [
-        url(r'xmlrpc/$', xmlrpc_views.xmlrpc, name='xmlrpc'),
-        url(r'^project/(?P<project_id>[^/]+)/pwclientrc/$',
-            pwclient_views.pwclientrc,
-            name='pwclientrc'),
-        # legacy redirect
-        url(r'^help/pwclient/$', about_views.redirect, name='help-pwclient'),
-    ]
-
 if settings.ENABLE_REST_API:
     if 'rest_framework' not in settings.INSTALLED_APPS:
         raise RuntimeError(
@@ -276,3 +265,10 @@ if settings.COMPAT_REDIR:
             bundle_views.bundle_mbox_redir,
             name='bundle-mbox-redir'),
     ]
+
+    urlpatterns += [
+        url(r'xmlrpc/$', removed_views.xmlrpc_removed),
+        url(r'^project/(?P<project_id>[^/]+)/pwclientrc/$',
+            removed_views.xmlrpc_removed),
+        url(r'^help/pwclient/$', removed_views.xmlrpc_removed),
+    ]
diff --git patchwork/views/about.py patchwork/views/about.py
index 91c3b74ebf8f..0a6f75d3b218 100644
--- patchwork/views/about.py
+++ patchwork/views/about.py
@@ -14,7 +14,6 @@ def about(request):
     context = {
         'enabled_apis': {
             'rest': settings.ENABLE_REST_API,
-            'xmlrpc': settings.ENABLE_XMLRPC,
         },
         'admins': () if settings.ADMINS_HIDE else settings.ADMINS,
     }
@@ -23,8 +22,5 @@ def about(request):
 
 
 def redirect(request):
-    """Redirect for legacy URLs.
-
-    Remove this when Patchwork 3.0 is released.
-    """
+    """Redirect for legacy URLs."""
     return HttpResponsePermanentRedirect(reverse('about'))
diff --git patchwork/views/project.py patchwork/views/project.py
index 8fa41794f5db..621457d5b732 100644
--- patchwork/views/project.py
+++ patchwork/views/project.py
@@ -3,7 +3,6 @@
 #
 # SPDX-License-Identifier: GPL-2.0-or-later
 
-from django.conf import settings
 from django.contrib.auth.models import User
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404
@@ -59,6 +58,5 @@ def project_detail(request, project_id):
             profile__maintainer_projects=project).select_related('profile'),
         'n_patches': n_patches[False] if False in n_patches else 0,
         'n_archived_patches': n_patches[True] if True in n_patches else 0,
-        'enable_xmlrpc': settings.ENABLE_XMLRPC,
     }
     return render(request, 'patchwork/project.html', context)
diff --git patchwork/views/pwclient.py patchwork/views/pwclient.py
deleted file mode 100644
index 72ebcbbb9e90..000000000000
--- patchwork/views/pwclient.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# Patchwork - automated patch tracking system
-# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
-#
-# SPDX-License-Identifier: GPL-2.0-or-later
-
-from django.conf import settings
-from django.shortcuts import get_object_or_404
-from django.shortcuts import render
-
-from patchwork.models import Project
-
-
-def pwclientrc(request, project_id):
-    project = get_object_or_404(Project, linkname=project_id)
-
-    context = {
-        'project': project,
-    }
-    if settings.FORCE_HTTPS_LINKS or request.is_secure():
-        context['scheme'] = 'https'
-    else:
-        context['scheme'] = 'http'
-
-    response = render(request, 'patchwork/pwclientrc', context,
-                      content_type='text/plain')
-    response['Content-Disposition'] = 'attachment; filename=.pwclientrc'
-
-    return response
diff --git patchwork/views/removed.py patchwork/views/removed.py
new file mode 100644
index 000000000000..7db3c0e3b8d0
--- /dev/null
+++ patchwork/views/removed.py
@@ -0,0 +1,11 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2019 IBM Corporation
+# Author: Daniel Axtens <dja@axtens.net>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from django.shortcuts import render
+
+
+def xmlrpc_removed(request, project_id=None):
+    return render(request, 'patchwork/xmlrpc-removed.html', {}, status=410)
diff --git patchwork/views/xmlrpc.py patchwork/views/xmlrpc.py
deleted file mode 100644
index f60725044ebe..000000000000
--- patchwork/views/xmlrpc.py
+++ /dev/null
@@ -1,951 +0,0 @@
-# Patchwork - automated patch tracking system
-# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
-#
-# SPDX-License-Identifier: GPL-2.0-or-later
-
-import base64
-# NOTE(stephenfin) six does not seem to support this
-try:
-    from DocXMLRPCServer import XMLRPCDocGenerator
-except ImportError:
-    from xmlrpc.server import XMLRPCDocGenerator
-import sys
-
-from django.contrib.auth import authenticate
-from django.http import HttpResponse
-from django.http import HttpResponseRedirect
-from django.http import HttpResponseServerError
-from django.views.decorators.csrf import csrf_exempt
-from django.urls import reverse
-from django.utils import six
-from django.utils.six.moves import xmlrpc_client
-from django.utils.six.moves.xmlrpc_server import SimpleXMLRPCDispatcher
-
-from patchwork.models import Check
-from patchwork.models import Patch
-from patchwork.models import Person
-from patchwork.models import Project
-from patchwork.models import State
-from patchwork.views.utils import patch_to_mbox
-
-
-class PatchworkXMLRPCDispatcher(SimpleXMLRPCDispatcher,
-                                XMLRPCDocGenerator):
-
-    server_name = 'Patchwork XML-RPC API'
-    server_title = 'Patchwork XML-RPC API v1 Documentation'
-
-    def __init__(self):
-        SimpleXMLRPCDispatcher.__init__(self, allow_none=False,
-                                        encoding=None)
-        XMLRPCDocGenerator.__init__(self)
-
-        def _dumps(obj, *args, **kwargs):
-            kwargs['allow_none'] = self.allow_none
-            kwargs['encoding'] = self.encoding
-            return xmlrpc_client.dumps(obj, *args, **kwargs)
-
-        self.dumps = _dumps
-
-        # map of name => (auth, func)
-        self.func_map = {}
-
-    def register_function(self, fn, auth_required):
-        self.funcs[fn.__name__] = fn  # needed by superclass methods
-        self.func_map[fn.__name__] = (auth_required, fn)
-
-    def _user_for_request(self, request):
-        auth_header = None
-
-        if 'HTTP_AUTHORIZATION' in request.META:
-            auth_header = request.META.get('HTTP_AUTHORIZATION')
-        elif 'Authorization' in request.META:
-            auth_header = request.META.get('Authorization')
-
-        if auth_header is None or auth_header == '':
-            raise Exception('No authentication credentials given')
-
-        header = auth_header.strip()
-
-        if not header.startswith('Basic '):
-            raise Exception('Authentication scheme not supported')
-
-        header = header[len('Basic '):].strip()
-
-        try:
-            decoded = base64.b64decode(header.encode('ascii')).decode('ascii')
-            username, password = decoded.split(':', 1)
-        except ValueError:
-            raise Exception('Invalid authentication credentials')
-
-        return authenticate(username=username, password=password)
-
-    def _dispatch(self, request, method, params):
-        if method not in list(self.func_map.keys()):
-            raise Exception('method "%s" is not supported' % method)
-
-        auth_required, fn = self.func_map[method]
-
-        if auth_required:
-            user = self._user_for_request(request)
-            if not user:
-                raise Exception('Invalid username/password')
-
-            params = (user,) + params
-
-        return fn(*params)
-
-    def _marshaled_dispatch(self, request):
-        try:
-            params, method = six.moves.xmlrpc_client.loads(request.body)
-
-            response = self._dispatch(request, method, params)
-            # wrap response in a singleton tuple
-            response = (response,)
-            response = self.dumps(response, methodresponse=1)
-        except six.moves.xmlrpc_client.Fault as fault:
-            response = self.dumps(fault)
-        except Exception:  # noqa
-            # report exception back to server
-            response = self.dumps(
-                six.moves.xmlrpc_client.Fault(
-                    1, '%s:%s' % (sys.exc_info()[0], sys.exc_info()[1])),
-            )
-
-        return response
-
-
-dispatcher = PatchworkXMLRPCDispatcher()
-
-# XMLRPC view function
-
-
-@csrf_exempt
-def xmlrpc(request):
-    if request.method not in ['POST', 'GET']:
-        return HttpResponseRedirect(reverse('project-list'))
-
-    response = HttpResponse()
-
-    if request.method == 'POST':
-        try:
-            ret = dispatcher._marshaled_dispatch(request)
-        except Exception:  # noqa
-            return HttpResponseServerError()
-    else:
-        ret = dispatcher.generate_html_documentation()
-
-    response.write(ret)
-
-    return response
-
-# decorator for XMLRPC methods. Setting login_required to true will call
-# the decorated function with a non-optional user as the first argument.
-
-
-def xmlrpc_method(login_required=False):
-    def wrap(f):
-        dispatcher.register_function(f, login_required)
-        return f
-
-    return wrap
-
-
-# We allow most of the Django field lookup types for remote queries
-LOOKUP_TYPES = ['iexact', 'contains', 'icontains', 'gt', 'gte', 'lt',
-                'in', 'startswith', 'istartswith', 'endswith',
-                'iendswith', 'range', 'year', 'month', 'day', 'isnull']
-
-
-#######################################################################
-# Helper functions
-#######################################################################
-
-def project_to_dict(obj):
-    """Serialize a project object.
-
-    Return a trimmed down dictionary representation of a Project
-    object which is safe to send to the client. For example:
-
-    {
-        'id': 1,
-        'linkname': 'my-project',
-        'name': 'My Project',
-    }
-
-    Args:
-        Project object to serialize.
-
-    Returns:
-        Serialized Project object.
-    """
-    return {
-        'id': obj.id,
-        'linkname': obj.linkname,
-        'name': obj.name,
-    }
-
-
-def person_to_dict(obj):
-    """Serialize a person object.
-
-    Return a trimmed down dictionary representation of a Person
-    object which is safe to send to the client. For example:
-
-    {
-        'id': 1,
-        'email': 'joe.bloggs@example.com',
-        'name': 'Joe Bloggs',
-        'user': None,
-    }
-
-    Args:
-        Person object to serialize.
-
-    Returns:
-        Serialized Person object.
-    """
-
-    # Make sure we don't return None even if the user submitted a patch
-    # with no real name.  XMLRPC can't marshall None.
-    if obj.name is not None:
-        name = obj.name
-    else:
-        name = obj.email
-
-    return {
-        'id': obj.id,
-        'email': obj.email,
-        'name': name,
-        'user': six.text_type(obj.user).encode('utf-8'),
-    }
-
-
-def patch_to_dict(obj):
-    """Serialize a patch object.
-
-    Return a trimmed down dictionary representation of a Patch
-    object which is safe to send to the client. For example:
-
-    {
-        'id': 1
-        'date': '2000-12-31 00:11:22',
-        'filename': 'Fix-all-the-bugs.patch',
-        'msgid': '<BLU438-SMTP36690BBDD2CE71A7138B082511A@phx.gbl>',
-        'name': "Fix all the bugs",
-        'project': 'my-project',
-        'project_id': 1,
-        'state': 'New',
-        'state_id': 1,
-        'archived': False,
-        'submitter': 'Joe Bloggs <joe.bloggs at example.com>',
-        'submitter_id': 1,
-        'delegate': 'admin',
-        'delegate_id': 1,
-        'commit_ref': '',
-        'hash': '',
-    }
-
-    Args:
-        Patch object to serialize.
-
-    Returns:
-        Serialized Patch object.
-    """
-    return {
-        'id': obj.id,
-        'date': six.text_type(obj.date).encode('utf-8'),
-        'filename': obj.filename,
-        'msgid': obj.msgid,
-        'name': obj.name,
-        'project': six.text_type(obj.project).encode('utf-8'),
-        'project_id': obj.project_id,
-        'state': six.text_type(obj.state).encode('utf-8'),
-        'state_id': obj.state_id,
-        'archived': obj.archived,
-        'submitter': six.text_type(obj.submitter).encode('utf-8'),
-        'submitter_id': obj.submitter_id,
-        'delegate': six.text_type(obj.delegate).encode('utf-8'),
-        'delegate_id': obj.delegate_id or 0,
-        'commit_ref': obj.commit_ref or '',
-        'hash': obj.hash or '',
-    }
-
-
-def state_to_dict(obj):
-    """Serialize a state object.
-
-    Return a trimmed down dictionary representation of a State
-    object which is safe to send to the client. For example:
-
-    {
-        'id': 1,
-        'name': 'New',
-    }
-
-    Args:
-        State object to serialize.
-
-    Returns:
-        Serialized State object.
-    """
-    return {
-        'id': obj.id,
-        'name': obj.name,
-    }
-
-
-def check_to_dict(obj):
-    """Return a trimmed down dictionary representation of a Check
-    object which is OK to send to the client."""
-    return {
-        'id': obj.id,
-        'date': six.text_type(obj.date).encode('utf-8'),
-        'patch': six.text_type(obj.patch).encode('utf-8'),
-        'patch_id': obj.patch_id,
-        'user': six.text_type(obj.user).encode('utf-8'),
-        'user_id': obj.user_id,
-        'state': obj.get_state_display(),
-        'target_url': obj.target_url,
-        'description': obj.description,
-        'context': obj.context,
-    }
-
-
-def patch_check_to_dict(obj):
-    """Return a combined patch check."""
-    return {
-        'state': obj.combined_check_state,
-        'total': len(obj.checks),
-        'checks': [check_to_dict(check) for check in obj.checks]
-    }
-
-
-#######################################################################
-# Public XML-RPC methods
-#######################################################################
-
-def _get_objects(serializer, objects, max_count):
-    if max_count > 0:
-        return [serializer(x) for x in objects[:max_count]]
-    elif max_count < 0:
-        min_count = objects.count() + max_count
-        return [serializer(x) for x in objects[min_count:]]
-    else:
-        return [serializer(x) for x in objects]
-
-
-@xmlrpc_method()
-def pw_rpc_version():
-    """Return Patchwork XML-RPC interface version.
-
-    The API is versioned separately from patchwork itself. The API
-    version only changes when the API itself changes. As these changes
-    can include the removal or modification of methods, it is highly
-    recommended that one first test the API version for compatibility
-    before making method calls.
-
-    History:
-
-        1.0.0: Patchwork 1.0 release
-        1.1.0: ???
-        1.2.0: ???
-        1.3.0: Add support for negative indexing of Checks
-
-    Returns:
-        Version of the API.
-    """
-    return (1, 3, 0)
-
-
-@xmlrpc_method()
-def project_list(search_str=None, max_count=0):
-    """List projects matching a given linkname filter.
-
-    Filter projects by linkname. Projects are compared to the search
-    string via a case-insensitive containment test, a.k.a. a partial
-    match.
-
-    Args:
-        search_str: The string to compare project names against. If
-            blank, all projects will be returned.
-        max_count (int): The maximum number of projects to return.
-
-    Returns:
-        A serialized list of projects matching filter, if any. A list
-        of all projects if no filter given.
-    """
-    if search_str:
-        projects = Project.objects.filter(linkname__icontains=search_str)
-    else:
-        projects = Project.objects.all()
-
-    return _get_objects(project_to_dict, projects, max_count)
-
-
-@xmlrpc_method()
-def project_get(project_id):
-    """Get a project by its ID.
-
-    Retrieve a project matching a given project ID, if any exists.
-
-    Args:
-        project_id (int): The ID of the project to retrieve.
-
-    Returns:
-        The serialized project matching the ID, if any, else an empty
-        dict.
-    """
-    try:
-        project = Project.objects.get(id=project_id)
-        return project_to_dict(project)
-    except Project.DoesNotExist:
-        return {}
-
-
-@xmlrpc_method()
-def person_list(search_str=None, max_count=0):
-    """List persons matching a given name or email filter.
-
-    Filter persons by name and email. Persons are compared to the
-    search string via a case-insensitive containment test, a.k.a. a
-    partial match.
-
-    Args:
-        search_str: The string to compare person names or emails
-            against. If blank, all persons will be returned.
-        max_count (int): The maximum number of persons to return.
-
-    Returns:
-        A serialized list of persons matching filter, if any. A list
-        of all persons if no filter given.
-    """
-    if search_str:
-        people = (Person.objects.filter(name__icontains=search_str) |
-                  Person.objects.filter(email__icontains=search_str))
-    else:
-        people = Person.objects.all()
-
-    return _get_objects(person_to_dict, people, max_count)
-
-
-@xmlrpc_method()
-def person_get(person_id):
-    """Get a person by its ID.
-
-    Retrieve a person matching a given person ID, if any exists.
-
-    Args:
-        person_id (int): The ID of the person to retrieve.
-
-    Returns:
-        The serialized person matching the ID, if any, else an empty
-        dict.
-    """
-    try:
-        person = Person.objects.get(id=person_id)
-        return person_to_dict(person)
-    except Person.DoesNotExist:
-        return {}
-
-
-@xmlrpc_method()
-def patch_list(filt=None):
-    """List patches matching all of a given set of filters.
-
-    Filter patches by one or more of the below fields:
-
-     * id
-     * name
-     * project_id
-     * submitter_id
-     * delegate_id
-     * archived
-     * state_id
-     * date
-     * commit_ref
-     * hash
-     * msgid
-
-    It is also possible to specify the number of patches returned via
-    a ``max_count`` filter.
-
-     * max_count
-
-    With the exception of ``max_count``, the specified field of the
-    patches are compared to the search string using a provided
-    field lookup type, which can be one of:
-
-     * iexact
-     * contains
-     * icontains
-     * gt
-     * gte
-     * lt
-     * in
-     * startswith
-     * istartswith
-     * endswith
-     * iendswith
-     * range
-     * year
-     * month
-     * day
-     * isnull
-
-    Please refer to the Django documentation for more information on
-    these field lookup types.
-
-    An example filter would look like so:
-
-    {
-        'name__icontains': 'Joe Bloggs',
-        'max_count': 1,
-    }
-
-    Args:
-        filt (dict): The filters specifying the field to compare, the
-            lookup type and the value to compare against. Keys are of
-            format ``[FIELD_NAME]`` or ``[FIELD_NAME]__[LOOKUP_TYPE]``.
-            Example: ``name__icontains``. Values are plain strings to
-            compare against.
-
-    Returns:
-        A serialized list of patches matching filters, if any. A list
-        of all patches if no filter given.
-    """
-    if filt is None:
-        filt = {}
-
-    # We allow access to many of the fields.  But, some fields are
-    # filtered by raw object so we must lookup by ID instead over
-    # XML-RPC.
-    ok_fields = [
-        'id',
-        'name',
-        'project_id',
-        'submitter_id',
-        'delegate_id',
-        'archived',
-        'state_id',
-        'date',
-        'commit_ref',
-        'hash',
-        'msgid',
-        'max_count',
-    ]
-
-    dfilter = {}
-    max_count = 0
-
-    for key in filt:
-        parts = key.split('__')
-        if parts[0] not in ok_fields:
-            # Invalid field given
-            return []
-        if len(parts) > 1 and LOOKUP_TYPES.count(parts[1]) == 0:
-            # Invalid lookup type given
-            return []
-
-        try:
-            if parts[0] == 'project_id':
-                dfilter['project'] = Project.objects.get(id=filt[key])
-            elif parts[0] == 'submitter_id':
-                dfilter['submitter'] = Person.objects.get(id=filt[key])
-            elif parts[0] == 'delegate_id':
-                dfilter['delegate'] = Person.objects.get(id=filt[key])
-            elif parts[0] == 'state_id':
-                dfilter['state'] = State.objects.get(id=filt[key])
-            elif parts[0] == 'max_count':
-                max_count = filt[key]
-            else:
-                dfilter[key] = filt[key]
-        except (Project.DoesNotExist, Person.DoesNotExist, State.DoesNotExist):
-            # Invalid Project, Person or State given
-            return []
-
-    patches = Patch.objects.filter(**dfilter)
-
-    # Only extract the relevant fields. This saves a big db load as we
-    # no longer fetch content/headers/etc for potentially every patch
-    # in a project.
-    patches = patches.defer('content', 'headers', 'diff')
-
-    return _get_objects(patch_to_dict, patches, max_count)
-
-
-@xmlrpc_method()
-def patch_get(patch_id):
-    """Get a patch by its ID.
-
-    Retrieve a patch matching a given patch ID, if any exists.
-
-    Args:
-        patch_id (int): The ID of the patch to retrieve
-
-    Returns:
-        The serialized patch matching the ID, if any, else an empty
-        dict.
-    """
-    try:
-        patch = Patch.objects.get(id=patch_id)
-        return patch_to_dict(patch)
-    except Patch.DoesNotExist:
-        return {}
-
-
-@xmlrpc_method()
-def patch_get_by_hash(hash):  # noqa
-    """Get a patch by its hash.
-
-    Retrieve a patch matching a given patch hash, if any exists.
-
-    Args:
-        hash: The hash of the patch to retrieve
-
-    Returns:
-        The serialized patch matching the hash, if any, else an empty
-        dict.
-    """
-    try:
-        patch = Patch.objects.get(hash=hash)
-        return patch_to_dict(patch)
-    except Patch.DoesNotExist:
-        return {}
-
-
-@xmlrpc_method()
-def patch_get_by_project_hash(project, hash):
-    """Get a patch by its project and hash.
-
-    Retrieve a patch matching a given project and patch hash, if any
-    exists.
-
-    Args:
-        project (str): The project of the patch to retrieve.
-        hash: The hash of the patch to retrieve.
-
-    Returns:
-        The serialized patch matching both the project and the hash,
-        if any, else an empty dict.
-    """
-    try:
-        patch = Patch.objects.get(project__linkname=project,
-                                  hash=hash)
-        return patch_to_dict(patch)
-    except Patch.DoesNotExist:
-        return {}
-
-
-@xmlrpc_method()
-def patch_get_mbox(patch_id):
-    """Get a patch by its ID in mbox format.
-
-    Retrieve a patch matching a given patch ID, if any exists, and
-    return in mbox format.
-
-    Args:
-        patch_id (int): The ID of the patch to retrieve.
-
-    Returns:
-        The serialized patch matching the ID, if any, in mbox format,
-        else an empty string.
-    """
-    try:
-        patch = Patch.objects.get(id=patch_id)
-        return patch_to_mbox(patch)
-    except Patch.DoesNotExist:
-        return ''
-
-
-@xmlrpc_method()
-def patch_get_diff(patch_id):
-    """Get a patch by its ID in diff format.
-
-    Retrieve a patch matching a given patch ID, if any exists, and
-    return in diff format.
-
-    Args:
-        patch_id (int): The ID of the patch to retrieve.
-
-    Returns:
-        The serialized patch matching the ID, if any, in diff format,
-        else an empty string.
-    """
-    try:
-        patch = Patch.objects.get(id=patch_id)
-        return patch.diff
-    except Patch.DoesNotExist:
-        return ''
-
-
-@xmlrpc_method(login_required=True)
-def patch_set(user, patch_id, params):
-    """Set fields of a patch.
-
-    Modify a patch matching a given patch ID, if any exists, and using
-    the provided ``key,value`` pairs. Only the following parameters may
-    be set:
-
-     * state
-     * commit_ref
-     * archived
-
-    Any other field will be rejected.
-
-    **NOTE:** Authentication is required for this method.
-
-    Args:
-        user (User): The user making the request. This will be
-            populated from HTTP Basic Auth.
-        patch_id (int): The ID of the patch to modify.
-        params (dict): A dictionary of keys corresponding to patch
-            object fields and the values that said fields should be
-            set to.
-
-    Returns:
-        True, if successful else raise exception.
-
-    Raises:
-        Exception: User did not have necessary permissions to edit this
-            patch
-        Patch.DoesNotExist: The patch did not exist.
-    """
-    ok_params = ['state', 'commit_ref', 'archived']
-
-    patch = Patch.objects.get(id=patch_id)
-
-    if not patch.is_editable(user):
-        raise Exception('No permissions to edit this patch')
-
-    for (k, v) in params.items():
-        if k not in ok_params:
-            continue
-
-        if k == 'state':
-            patch.state = State.objects.get(id=v)
-
-        else:
-            setattr(patch, k, v)
-
-    patch.save()
-
-    return True
-
-
-@xmlrpc_method()
-def state_list(search_str=None, max_count=0):
-    """List states matching a given name filter.
-
-    Filter states by name. States are compared to the search string
-    via a case-insensitive containment test, a.k.a. a partial match.
-
-    Args:
-        search_str: The string to compare state names against. If
-            blank, all states will be returned.
-        max_count (int): The maximum number of states to return.
-
-    Returns:
-        A serialized list of states matching filter, if any. A list
-        of all states if no filter given.
-    """
-    if search_str:
-        states = State.objects.filter(name__icontains=search_str)
-    else:
-        states = State.objects.all()
-
-    return _get_objects(state_to_dict, states, max_count)
-
-
-@xmlrpc_method()
-def state_get(state_id):
-    """Get a state by its ID.
-
-    Retrieve a state matching a given state ID, if any exists.
-
-    Args:
-        state_id: The ID of the state to retrieve.
-
-    Returns:
-        The serialized state matching the ID, if any, else an empty
-        dict.
-    """
-    try:
-        state = State.objects.get(id=state_id)
-        return state_to_dict(state)
-    except State.DoesNotExist:
-        return {}
-
-
-@xmlrpc_method()
-def check_list(filt=None):
-    """List checks matching all of a given set of filters.
-
-    Filter checks by one or more of the below fields:
-
-     * id
-     * user
-     * project_id
-     * patch_id
-
-    It is also possible to specify the number of patches returned via
-    a ``max_count`` filter.
-
-     * max_count
-
-    With the exception of ``max_count``, the specified field of the
-    patches are compared to the search string using a provided
-    field lookup type, which can be one of:
-
-     * iexact
-     * contains
-     * icontains
-     * gt
-     * gte
-     * lt
-     * in
-     * startswith
-     * istartswith
-     * endswith
-     * iendswith
-     * range
-     * year
-     * month
-     * day
-     * isnull
-
-    Please refer to the Django documentation for more information on
-    these field lookup types.
-
-    An example filter would look like so:
-
-    {
-        'user__icontains': 'Joe Bloggs',
-        'max_count': 1,
-    }
-
-    Args:
-        filt (dict): The filters specifying the field to compare, the
-            lookup type and the value to compare against. Keys are of
-            format ``[FIELD_NAME]`` or ``[FIELD_NAME]__[LOOKUP_TYPE]``.
-            Example: ``name__icontains``. Values are plain strings to
-            compare against.
-
-    Returns:
-        A serialized list of Checks matching filters, if any. A list
-        of all Checks if no filter given.
-    """
-    if filt is None:
-        filt = {}
-
-    # We allow access to many of the fields. But, some fields are
-    # filtered by raw object so we must lookup by ID instead over
-    # XML-RPC.
-    ok_fields = [
-        'id',
-        'user',
-        'project_id',
-        'patch_id',
-        'max_count',
-    ]
-
-    dfilter = {}
-    max_count = 0
-
-    for key in filt:
-        parts = key.split('__')
-        if parts[0] not in ok_fields:
-            # Invalid field given
-            return []
-        if len(parts) > 1:
-            if LOOKUP_TYPES.count(parts[1]) == 0:
-                # Invalid lookup type given
-                return []
-
-        if parts[0] == 'user_id':
-            dfilter['user'] = Person.objects.filter(id=filt[key])[0]
-        if parts[0] == 'project_id':
-            dfilter['patch__project'] = Project.objects.filter(
-                id=filt[key])[0]
-        elif parts[0] == 'patch_id':
-            dfilter['patch'] = Patch.objects.filter(id=filt[key])[0]
-        elif parts[0] == 'max_count':
-            max_count = filt[key]
-        else:
-            dfilter[key] = filt[key]
-
-    checks = Check.objects.filter(**dfilter)
-
-    return _get_objects(check_to_dict, checks, max_count)
-
-
-@xmlrpc_method()
-def check_get(check_id):
-    """Get a check by its ID.
-
-    Retrieve a check matching a given check ID, if any exists.
-
-    Args:
-        check_id (int): The ID of the check to retrieve
-
-    Returns:
-        The serialized check matching the ID, if any, else an empty
-        dict.
-    """
-    try:
-        check = Check.objects.get(id=check_id)
-        return check_to_dict(check)
-    except Check.DoesNotExist:
-        return {}
-
-
-@xmlrpc_method(login_required=True)
-def check_create(user, patch_id, context, state, target_url="",
-                 description=""):
-    """Add a Check to a patch.
-
-    **NOTE:** Authentication is required for this method.
-
-    Args:
-        patch_id (id): The ID of the patch to create the check against.
-        context: Type of test or system that generated this check.
-        state: "pending", "success", "warning", or "fail"
-        target_url: Link to artifact(s) relating to this check.
-        description: A brief description of the check.
-
-    Returns:
-        True, if successful else raise exception.
-    """
-    patch = Patch.objects.get(id=patch_id)
-    if not patch.is_editable(user):
-        raise Exception('No permissions to edit this patch')
-    for state_val, state_str in Check.STATE_CHOICES:
-        if state == state_str:
-            state = state_val
-            break
-    else:
-        raise Exception("Invalid check state: %s" % state)
-    Check.objects.create(patch=patch, context=context, state=state, user=user,
-                         target_url=target_url, description=description)
-    return True
-
-
-@xmlrpc_method()
-def patch_check_get(patch_id):
-    """Get a patch's combined checks by its ID.
-
-    Retrieve a patch's combined checks for the patch matching a given
-    patch ID, if any exists.
-
-    Args:
-        patch_id (int): The ID of the patch to retrieve checks for
-
-    Returns:
-        The serialized combined patch checks matching the ID, if any,
-        else an empty dict.
-    """
-    try:
-        patch = Patch.objects.get(id=patch_id)
-        return patch_check_to_dict(patch)
-    except Patch.DoesNotExist:
-        return {}
diff --git releasenotes/notes/remove-xmlrpc-b6d26084338efcb4.yaml releasenotes/notes/remove-xmlrpc-b6d26084338efcb4.yaml
new file mode 100644
index 000000000000..e86c156844d8
--- /dev/null
+++ releasenotes/notes/remove-xmlrpc-b6d26084338efcb4.yaml
@@ -0,0 +1,11 @@
+---
+features:
+  - |
+    To simplify the code base, the XML-RPC API has been removed. This
+    means that `pwclient` will no longer work.
+
+    As a result, `tools/patchwork-update-commits` and
+    `tools/post-receive.hook` have also been removed.
+
+    Users are encouraged to try
+    `git-pw <https://github.com/getpatchwork/git-pw>`_.
diff --git tools/patchwork-update-commits tools/patchwork-update-commits
deleted file mode 100755
index 269dac9eee4e..000000000000
--- tools/patchwork-update-commits
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/bin/bash
-#
-# Patchwork - automated patch tracking system
-# Copyright (C) 2010 Jeremy Kerr <jk@ozlabs.org>
-#
-# SPDX-License-Identifier: GPL-2.0-or-later
-
-TOOLS_DIR="$(dirname "$0")"
-PW_DIR="${TOOLS_DIR}/../patchwork"
-
-if [ "$#" -lt 1 ]; then
-    echo "usage: $0 <revspec>" >&2
-    exit 1
-fi
-
-git rev-list --reverse "$@" |
-while read -r commit; do
-    hash=$(git diff "$commit~..$commit" | python "$PW_DIR/hasher.py")
-    pwclient update -s Accepted -c "$commit" -h "$hash"
-done
diff --git tools/post-receive.hook tools/post-receive.hook
deleted file mode 100755
index 9f2f0503d7ee..000000000000
--- tools/post-receive.hook
+++ /dev/null
@@ -1,86 +0,0 @@
-#!/bin/bash
-
-# Patchwork - automated patch tracking system
-# Copyright (C) 2010 martin f. krafft <madduck@madduck.net>
-#
-# SPDX-License-Identifier: GPL-2.0-or-later
-
-# Git post-receive hook to update Patchwork patches after Git pushes
-set -eu
-
-PW_DIR=/opt/patchwork/patchwork
-
-#TODO: the state map should really live in the repo's git-config
-STATE_MAP="refs/heads/master:Accepted"
-
-# ignore all commits already present in these refs
-# e.g.,
-#   EXCLUDE="refs/heads/upstream refs/heads/other-project"
-EXCLUDE=""
-
-do_exit=0
-trap "do_exit=1" INT
-
-get_patchwork_hash() {
-    local hash
-    hash=$(git diff "$1~..$1" | python $PW_DIR/hasher.py)
-    echo "$hash"
-    test -n "$hash"
-}
-
-get_patch_id() {
-    local id
-    id=$(pwclient info -h "$1" 2>/dev/null | \
-         sed -rne 's,- id[[:space:]]*: ,,p')
-    echo "$id"
-    test -n "$id"
-}
-
-set_patch_state() {
-    pwclient update -s "$2" -c "$3" "$1" 2>&1
-}
-
-update_patches() {
-    local cnt; cnt=0
-    for rev in $(git rev-parse --not ${EXCLUDE} |
-                 git rev-list --stdin --no-merges --reverse "${1}".."${2}"); do
-        if [ "$do_exit" = 1 ]; then
-            echo "I: exiting..." >&2
-            break
-        fi
-        hash=$(get_patchwork_hash "$rev")
-        if [ -z "$hash" ]; then
-            echo "E: failed to hash rev $rev." >&2
-            continue
-        fi
-        id=$(get_patch_id "$hash" || true)
-        if [ -z "$id" ]; then
-            echo "E: failed to find patch for rev $rev." >&2
-            continue
-        fi
-        reason="$(set_patch_state "$id" "$3" "$rev")"
-        if [ -n "$reason" ]; then
-            echo "E: failed to update patch #$id${reason:+: $reason}." >&2
-            continue
-        fi
-        echo "I: patch #$id updated using rev $rev." >&2
-        cnt=$((cnt + 1))
-    done
-
-    echo "I: $cnt patch(es) updated to state $3." >&2
-}
-
-while read -r oldrev newrev refname; do
-    found=0
-    for i in $STATE_MAP; do
-        key="${i%:*}"
-        if [ "$key" = "$refname" ]; then
-            update_patches "$oldrev" "$newrev" ${i#*:}
-            found=1
-            break
-        fi
-    done
-    if [ $found -eq 0 ]; then
-        echo "E: STATE_MAP has no mapping for branch $refname" >&2
-    fi
-done
