davidt99: @jmcarp Any plan to fix the review comments? Most of the driver requests (if not all) doesn't state content type, resulting warning logs in elastic 5.5.
The header support you wrote will be allow adding content type to all the request, thus fixing the warning.
HonzaKral: @davidt99 All requests are anotated with content-type accepted by elasticsearch since d862c8a (released in `5.2`). You can also set headers globally by specifying `headers={'custom': 'header'}` when instantiating `Elasticsearch`.
davidt99: @HonzaKral Oh, I was using 5.1 and looked for similar issue. Thanks.
jmcarp: I cleaned this up based on your suggestions @HonzaKral; let me know if this needs more revisions. I'm not sure why the tests are failing, but it looks like they're failing in the same way as `master`.
jmcarp: I rebased on master and dropped the deprecated methods, and reverted whitespace changes. This is ready for another look when you have time @HonzaKral.
fxdgear: This LGTM. Can you please rebase off master and push again? I had to change the version of ES that travis uses. 
diff --git a/elasticsearch/client/__init__.py b/elasticsearch/client/__init__.py
index 59858549..834f1d86 100644
--- a/elasticsearch/client/__init__.py
+++ b/elasticsearch/client/__init__.py
@@ -1127,7 +1127,8 @@ class Elasticsearch(object):
         if body in SKIP_IN_PATH:
             raise ValueError("Empty value passed for a required argument 'body'.")
         return self.transport.perform_request('POST', _make_path(index,
-            doc_type, '_bulk'), params=params, body=self._bulk_body(body))
+            doc_type, '_bulk'), params=params, body=self._bulk_body(body),
+            headers={'content-type': 'application/x-ndjson'})
 
     @query_params('max_concurrent_searches', 'pre_filter_shard_size',
         'search_type', 'typed_keys')
@@ -1159,7 +1160,8 @@ class Elasticsearch(object):
         if body in SKIP_IN_PATH:
             raise ValueError("Empty value passed for a required argument 'body'.")
         return self.transport.perform_request('GET', _make_path(index,
-            doc_type, '_msearch'), params=params, body=self._bulk_body(body))
+            doc_type, '_msearch'), params=params, body=self._bulk_body(body),
+            headers={'content-type': 'application/x-ndjson'})
 
     @query_params('field_statistics', 'fields', 'offsets', 'parent', 'payloads',
         'positions', 'preference', 'realtime', 'routing', 'term_statistics',
@@ -1363,7 +1365,8 @@ class Elasticsearch(object):
         if body in SKIP_IN_PATH:
             raise ValueError("Empty value passed for a required argument 'body'.")
         return self.transport.perform_request('GET', _make_path(index, doc_type,
-            '_msearch', 'template'), params=params, body=self._bulk_body(body))
+            '_msearch', 'template'), params=params, body=self._bulk_body(body),
+            headers={'content-type': 'application/x-ndjson'})
 
     @query_params('allow_no_indices', 'expand_wildcards', 'fields',
         'ignore_unavailable')
@@ -1387,3 +1390,4 @@ class Elasticsearch(object):
         """
         return self.transport.perform_request('GET', _make_path(index,
             '_field_caps'), params=params, body=body)
+
diff --git a/elasticsearch/connection/http_requests.py b/elasticsearch/connection/http_requests.py
index 59dd381c..b98e7772 100644
--- a/elasticsearch/connection/http_requests.py
+++ b/elasticsearch/connection/http_requests.py
@@ -61,13 +61,13 @@ class RequestsHttpConnection(Connection):
             warnings.warn(
                 'Connecting to %s using SSL with verify_certs=False is insecure.' % self.base_url)
 
-    def perform_request(self, method, url, params=None, body=None, timeout=None, ignore=()):
+    def perform_request(self, method, url, params=None, body=None, timeout=None, ignore=(), headers=None):
         url = self.base_url + url
         if params:
             url = '%s?%s' % (url, urlencode(params or {}))
 
         start = time.time()
-        request = requests.Request(method=method, url=url, data=body)
+        request = requests.Request(method=method, headers=headers, url=url, data=body)
         prepared_request = self.session.prepare_request(request)
         settings = self.session.merge_environment_settings(prepared_request.url, {}, None, None, None)
         send_kwargs = {'timeout': timeout or self.timeout}
diff --git a/elasticsearch/connection/http_urllib3.py b/elasticsearch/connection/http_urllib3.py
index 7b4e6c79..62957ed2 100644
--- a/elasticsearch/connection/http_urllib3.py
+++ b/elasticsearch/connection/http_urllib3.py
@@ -91,7 +91,7 @@ class Urllib3HttpConnection(Connection):
 
         self.pool = pool_class(host, port=port, timeout=self.timeout, maxsize=maxsize, **kw)
 
-    def perform_request(self, method, url, params=None, body=None, timeout=None, ignore=()):
+    def perform_request(self, method, url, params=None, body=None, timeout=None, ignore=(), headers=None):
         url = self.url_prefix + url
         if params:
             url = '%s?%s' % (url, urlencode(params))
@@ -111,6 +111,9 @@ class Urllib3HttpConnection(Connection):
             if not isinstance(method, str):
                 method = method.encode('utf-8')
 
+            if headers:
+                request_headers = dict(self.headers)
+                request_headers.update(headers or {})
             response = self.pool.urlopen(method, url, body, retries=False, headers=self.headers, **kw)
             duration = time.time() - start
             raw_data = response.data.decode('utf-8')
diff --git a/elasticsearch/transport.py b/elasticsearch/transport.py
index dc8cd891..f876a945 100644
--- a/elasticsearch/transport.py
+++ b/elasticsearch/transport.py
@@ -255,7 +255,7 @@ class Transport(object):
         if self.sniff_on_connection_fail:
             self.sniff_hosts()
 
-    def perform_request(self, method, url, params=None, body=None):
+    def perform_request(self, method, url, headers=None, params=None, body=None):
         """
         Perform the actual request. Retrieve a connection from the connection
         pool, pass all the information to it's perform_request method and
@@ -269,6 +269,8 @@ class Transport(object):
 
         :arg method: HTTP method to use
         :arg url: absolute url (without host) to target
+        :arg headers: dictionary of headers, will be handed over to the
+            underlying :class:`~elasticsearch.Connection` class
         :arg params: dictionary of query parameters, will be handed over to the
             underlying :class:`~elasticsearch.Connection` class for serialization
         :arg body: body of the request, will be serializes using serializer and
@@ -309,7 +311,7 @@ class Transport(object):
             connection = self.get_connection()
 
             try:
-                status, headers, data = connection.perform_request(method, url, params, body, ignore=ignore, timeout=timeout)
+                status, headers, data = connection.perform_request(method, url, params, body, headers=headers, ignore=ignore, timeout=timeout)
 
             except TransportError as e:
                 if method == 'HEAD' and e.status_code == 404:

Set `application/x-ndjson` content type on bulk requests
As of elastic 5.x, requests to `/_bulk` should set `Content-Type` to `application/x-ndjson`. If not, elastic logs a warning. It looks like this library defaults to `application/json`. To fix, I'm thinking we should accept an optional dict of headers at `https://github.com/elastic/elasticsearch-py/blob/master/elasticsearch/connection/http_urllib3.py#L94`. If that sounds reasonable, I'd be happy to submit a patch.

cc @cnelson @wjwoodson
diff --git a/test_elasticsearch/test_connection.py b/test_elasticsearch/test_connection.py
index b2d84996..e4e0ae63 100644
--- a/test_elasticsearch/test_connection.py
+++ b/test_elasticsearch/test_connection.py
@@ -104,6 +104,13 @@ class TestRequestsConnection(TestCase):
         self.assertEquals('GET', request.method)
         self.assertEquals(None, request.body)
 
+    def test_merge_headers(self):
+        con = self._get_mock_connection(connection_params={'headers': {'h1': 'v1', 'h2': 'v2'}})
+        req = self._get_request(con, 'GET', '/', headers={'h2': 'v2p', 'h3': 'v3'})
+        self.assertEquals(req.headers['h1'], 'v1')
+        self.assertEquals(req.headers['h2'], 'v2p')
+        self.assertEquals(req.headers['h3'], 'v3')
+
     def test_http_auth(self):
         con = RequestsHttpConnection(http_auth='username:secret')
         self.assertEquals(('username', 'secret'), con.session.auth)
diff --git a/test_elasticsearch/test_transport.py b/test_elasticsearch/test_transport.py
index 328325c1..50acb7fa 100644
--- a/test_elasticsearch/test_transport.py
+++ b/test_elasticsearch/test_transport.py
@@ -74,7 +74,7 @@ class TestTransport(TestCase):
         t.perform_request('GET', '/', params={'request_timeout': 42})
         self.assertEquals(1, len(t.get_connection().calls))
         self.assertEquals(('GET', '/', {}, None), t.get_connection().calls[0][0])
-        self.assertEquals({'timeout': 42, 'ignore': ()}, t.get_connection().calls[0][1])
+        self.assertEquals({'timeout': 42, 'ignore': (), 'headers': None}, t.get_connection().calls[0][1])
 
     def test_send_get_body_as_source(self):
         t = Transport([{}], send_get_body_as='source', connection_class=DummyConnection)

pip install -e .[develop]
parse_log_pytest
requirements.txt
nose coverage mock pyaml nosexcover pytest
3.6
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
attrs==22.2.0
Babel==2.11.0
certifi==2021.5.30
charset-normalizer==2.0.12
coverage==6.2
docutils==0.18.1
-e git+https://github.com/elastic/elasticsearch-py.git@0397527d12bcb43274ce9054111c5f7f673a7ad6#egg=elasticsearch
idna==3.10
imagesize==1.4.1
importlib-metadata==4.8.3
iniconfig==1.1.1
Jinja2==3.0.3
MarkupSafe==2.0.1
mock==5.2.0
nose==1.3.7
nosexcover==1.0.11
packaging==21.3
pluggy==1.0.0
py==1.11.0
pyaml==23.5.8
Pygments==2.14.0
pyparsing==3.1.4
pytest==7.0.1
pytz==2025.2
PyYAML==6.0.1
requests==2.27.1
snowballstemmer==2.2.0
Sphinx==5.3.0
sphinx-rtd-theme==2.0.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
tomli==1.2.3
typing_extensions==4.1.1
urllib3==1.21.1
zipp==3.6.0

name: elasticsearch-py
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - attrs==22.2.0
      - babel==2.11.0
      - charset-normalizer==2.0.12
      - coverage==6.2
      - docutils==0.18.1
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==4.8.3
      - iniconfig==1.1.1
      - jinja2==3.0.3
      - markupsafe==2.0.1
      - mock==5.2.0
      - nose==1.3.7
      - nosexcover==1.0.11
      - packaging==21.3
      - pluggy==1.0.0
      - py==1.11.0
      - pyaml==23.5.8
      - pygments==2.14.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytz==2025.2
      - pyyaml==6.0.1
      - requests==2.27.1
      - snowballstemmer==2.2.0
      - sphinx==5.3.0
      - sphinx-rtd-theme==2.0.0
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - tomli==1.2.3
      - typing-extensions==4.1.1
      - urllib3==1.21.1
      - zipp==3.6.0
prefix: /opt/conda/envs/elasticsearch-py

test_elasticsearch/test_connection.py::TestRequestsConnection::test_merge_headers test_elasticsearch/test_transport.py::TestTransport::test_request_timeout_extracted_from_params_and_passed
test_elasticsearch/test_connection.py::TestUrllib3Connection::test_doesnt_use_https_if_not_specified test_elasticsearch/test_connection.py::TestUrllib3Connection::test_http_auth test_elasticsearch/test_connection.py::TestUrllib3Connection::test_http_auth_list test_elasticsearch/test_connection.py::TestUrllib3Connection::test_http_auth_tuple test_elasticsearch/test_connection.py::TestUrllib3Connection::test_keep_alive_is_on_by_default test_elasticsearch/test_connection.py::TestUrllib3Connection::test_timeout_set test_elasticsearch/test_connection.py::TestUrllib3Connection::test_uses_https_if_verify_certs_is_off test_elasticsearch/test_connection.py::TestRequestsConnection::test_body_attached test_elasticsearch/test_connection.py::TestRequestsConnection::test_conflict_error_is_returned_on_409 test_elasticsearch/test_connection.py::TestRequestsConnection::test_custom_http_auth_is_allowed test_elasticsearch/test_connection.py::TestRequestsConnection::test_defaults test_elasticsearch/test_connection.py::TestRequestsConnection::test_failed_request_logs_and_traces test_elasticsearch/test_connection.py::TestRequestsConnection::test_head_with_404_doesnt_get_logged test_elasticsearch/test_connection.py::TestRequestsConnection::test_http_auth test_elasticsearch/test_connection.py::TestRequestsConnection::test_http_auth_attached test_elasticsearch/test_connection.py::TestRequestsConnection::test_http_auth_list test_elasticsearch/test_connection.py::TestRequestsConnection::test_http_auth_tuple test_elasticsearch/test_connection.py::TestRequestsConnection::test_not_found_error_is_returned_on_404 test_elasticsearch/test_connection.py::TestRequestsConnection::test_params_properly_encoded test_elasticsearch/test_connection.py::TestRequestsConnection::test_repr test_elasticsearch/test_connection.py::TestRequestsConnection::test_request_error_is_returned_on_400 test_elasticsearch/test_connection.py::TestRequestsConnection::test_success_logs_and_traces test_elasticsearch/test_connection.py::TestRequestsConnection::test_timeout_set test_elasticsearch/test_connection.py::TestRequestsConnection::test_url_prefix test_elasticsearch/test_connection.py::TestRequestsConnection::test_uses_https_if_verify_certs_is_off test_elasticsearch/test_transport.py::TestHostsInfoCallback::test_master_only_nodes_are_ignored test_elasticsearch/test_transport.py::TestTransport::test_add_connection test_elasticsearch/test_transport.py::TestTransport::test_body_bytes_get_passed_untouched test_elasticsearch/test_transport.py::TestTransport::test_body_gets_encoded_into_bytes test_elasticsearch/test_transport.py::TestTransport::test_body_surrogates_replaced_encoded_into_bytes test_elasticsearch/test_transport.py::TestTransport::test_custom_connection_class test_elasticsearch/test_transport.py::TestTransport::test_failed_connection_will_be_marked_as_dead test_elasticsearch/test_transport.py::TestTransport::test_kwargs_passed_on_to_connection_pool test_elasticsearch/test_transport.py::TestTransport::test_kwargs_passed_on_to_connections test_elasticsearch/test_transport.py::TestTransport::test_request_will_fail_after_X_retries test_elasticsearch/test_transport.py::TestTransport::test_resurrected_connection_will_be_marked_as_live_on_success test_elasticsearch/test_transport.py::TestTransport::test_send_get_body_as_post test_elasticsearch/test_transport.py::TestTransport::test_send_get_body_as_source test_elasticsearch/test_transport.py::TestTransport::test_single_connection_uses_dummy_connection_pool test_elasticsearch/test_transport.py::TestTransport::test_sniff_after_n_seconds test_elasticsearch/test_transport.py::TestTransport::test_sniff_on_fail_triggers_sniffing_on_fail test_elasticsearch/test_transport.py::TestTransport::test_sniff_on_start_fetches_and_uses_nodes_list test_elasticsearch/test_transport.py::TestTransport::test_sniff_on_start_ignores_sniff_timeout test_elasticsearch/test_transport.py::TestTransport::test_sniff_reuses_connection_instances_if_possible test_elasticsearch/test_transport.py::TestTransport::test_sniff_uses_sniff_timeout test_elasticsearch/test_transport.py::TestTransport::test_sniff_will_use_seed_connections
elasticsearch/connection/http_urllib3.py elasticsearch/connection/http_requests.py elasticsearch/client/__init__.py elasticsearch/transport.py
elasticsearch/connection/http_urllib3.py elasticsearch/connection/http_requests.py elasticsearch/client/__init__.py elasticsearch/transport.py