diff --git a/django/utils/cache.py b/django/utils/cache.py
index 2b37acfd44..3ee01a1e94 100644
--- a/django/utils/cache.py
+++ b/django/utils/cache.py
@@ -98,7 +98,7 @@ def get_max_age(response):
 
 
 def set_response_etag(response):
-    if not response.streaming:
+    if not response.streaming and response.content:
         response['ETag'] = quote_etag(hashlib.md5(response.content).hexdigest())
     return response
 
diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt
index 7d1948b183..7fec7246d5 100644
--- a/docs/releases/3.1.txt
+++ b/docs/releases/3.1.txt
@@ -248,6 +248,10 @@ Miscellaneous
   ``/`` if not set). This change should not affect settings set to valid URLs
   or absolute paths.
 
+* :class:`~django.middleware.http.ConditionalGetMiddleware` no longer adds the
+  ``ETag`` header to responses with an empty
+  :attr:`~django.http.HttpResponse.content`.
+
 .. _deprecated-features-3.1:
 
 Features deprecated in 3.1
diff --git a/tests/middleware/tests.py b/tests/middleware/tests.py
index def313c5f7..6b6eded24d 100644
--- a/tests/middleware/tests.py
+++ b/tests/middleware/tests.py
@@ -452,6 +452,12 @@ class ConditionalGetMiddlewareTest(SimpleTestCase):
         res = StreamingHttpResponse(['content'])
         self.assertFalse(ConditionalGetMiddleware().process_response(self.req, res).has_header('ETag'))
 
+    def test_no_etag_response_empty_content(self):
+        res = HttpResponse()
+        self.assertFalse(
+            ConditionalGetMiddleware().process_response(self.req, res).has_header('ETag')
+        )
+
     def test_no_etag_no_store_cache(self):
         self.resp['Cache-Control'] = 'No-Cache, No-Store, Max-age=0'
         self.assertFalse(ConditionalGetMiddleware().process_response(self.req, self.resp).has_header('ETag'))