diff --git a/.gitignore b/.gitignore
index 73ace0515b1532052aef03b6796c1453114b3b4b..b5d81dd8ede298c45a069138e8d395bac9f642c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,5 @@ coverage.xml
 junit.xml
 .pytest_cache
 .tower_cli.cfg
+/app/static/files/*
+!/app/static/files/.empty
diff --git a/app/inventory/views.py b/app/inventory/views.py
index 60efbb15a0942a6dff94614b948c8e1b56d05eea..ef61248334e06b94fb707da87c515f446ce3047d 100644
--- a/app/inventory/views.py
+++ b/app/inventory/views.py
@@ -122,6 +122,16 @@ def list_items():
     return render_template("inventory/items.html")
 
 
+@bp.route("/items/_generate_excel_file")
+@login_required
+def _generate_excel_file():
+    task = current_user.launch_task(
+        "generate_items_excel_file", func="generate_items_excel_file", timeout=180
+    )
+    db.session.commit()
+    return utils.redirect_to_job_status(task.id)
+
+
 @bp.route("/items/create", methods=("GET", "POST"))
 @login_groups_accepted("admin", "create")
 def create_item():
diff --git a/app/main/views.py b/app/main/views.py
index c52f4b0188cc549d231a1c557951880da94a25e8..5e5171c21588f1990e39a12c5b1f90335106bacc 100644
--- a/app/main/views.py
+++ b/app/main/views.py
@@ -14,7 +14,7 @@ import redis
 import rq_dashboard
 from flask import Blueprint, render_template, jsonify, g, current_app, abort
 from flask_login import login_required, current_user
-from rq import push_connection, pop_connection
+from rq import push_connection, pop_connection, Queue
 from ..extensions import sentry
 from .. import utils
 
@@ -98,3 +98,27 @@ def pop_rq_connection(exception=None):
 @login_required
 def index():
     return render_template("index.html")
+
+
+@bp.route("/status/<job_id>")
+@login_required
+def job_status(job_id):
+    """Return the status of the job
+
+    :param int job_id: The id of the job to pull
+    :return: json with the status of the job
+    """
+    q = Queue()
+    job = q.fetch_job(job_id)
+    if job is None:
+        response = {"status": "unknown"}
+    else:
+        response = {
+            "status": job.get_status(),
+            "result": job.result,
+            "func_name": job.func_name.split(".")[-1],
+            "progress": job.meta.get("progress"),
+        }
+        if job.is_failed:
+            response["message"] = job.meta.get("error", "Job failed")
+    return jsonify(response)
diff --git a/app/models.py b/app/models.py
index bfa25575d33e78a33d78c837712d4c7809e66d1f..0a5eed59ac93192e7a4345b52d46af35fb100ce2 100644
--- a/app/models.py
+++ b/app/models.py
@@ -512,6 +512,18 @@ class Item(CreatedMixin, db.Model):
         )
         return d
 
+    def to_row_dict(self):
+        """Convert to a dict that can easily be exported to an excel row
+
+        All values should be a string
+        """
+        d = self.to_dict().copy()
+        d["children"] = " ".join(d["children"])
+        d["macs"] = " ".join(d["macs"])
+        d["comments"] = "\n\n".join(d["comments"])
+        d["history"] = "\n".join([str(version) for version in d["history"]])
+        return d
+
     def history(self):
         versions = []
         for version in self.versions:
diff --git a/app/settings.py b/app/settings.py
index c9db033affa848292b2408e224bf6e95f5d94606..14784a87c4cb46edf2e9a6fd0e73dd7ad3340216 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -94,3 +94,6 @@ CSENTRY_RELEASE = raven.fetch_git_sha(Path(__file__).parents[1])
 SENTRY_DSN = os.environ.get("SENTRY_DSN", "")
 SENTRY_USER_ATTRS = ["username"]
 SENTRY_CONFIG = {"release": CSENTRY_RELEASE}
+
+# Static local files
+CSENTRY_STATIC_FILES = Path(__file__).parent / "static" / "files"
diff --git a/app/static/files/.empty b/app/static/files/.empty
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/app/static/js/csentry.js b/app/static/js/csentry.js
index 57274001b55807e27504d7d7fc8db928cfc97d8c..c3f223c83d566d178cd71c77fcde6837ddc27432 100644
--- a/app/static/js/csentry.js
+++ b/app/static/js/csentry.js
@@ -11,6 +11,54 @@ function remove_alerts() {
   });
 }
 
+function update_progress_bar(value) {
+  // Assume that there is only one progress bar
+  $(".progress-bar")
+    .css("width", value + "%")
+    .attr("aria-valuenow", value)
+    .text(value + "%");
+}
+
+// Send a request to the server every 500 ms
+// until the job is done
+function check_job_status(status_url, $modal) {
+  // cache shall be set to false for this to work in IE!
+  $.ajax({
+    dataType: "json",
+    url: status_url,
+    cache: false,
+    success: function(data, status, request) {
+      switch (data.status) {
+        case "unknown":
+          $modal.modal('hide');
+          flash_alert("Unknown job id", "danger");
+          break;
+        case "finished":
+          $modal.modal('hide');
+          switch (data.func_name) {
+            case "generate_items_excel_file":
+              window.location.href = $SCRIPT_ROOT + "/static/files/" + data.result;
+              break;
+          }
+          break;
+        case "failed":
+          $modal.modal('hide');
+          flash_alert(data.message, "danger");
+          break;
+        case "started":
+          if( data.progress !== null ) {
+            update_progress_bar(data.progress);
+          }
+        default:
+          // queued/started/deferred
+          setTimeout(function() {
+            check_job_status(status_url, $modal);
+          }, 500);
+      }
+    }
+  });
+}
+
 // Function to dynamically update a select field
 function update_selectfield(field_id, data, selected_value) {
   var $field = $(field_id);
diff --git a/app/static/js/items.js b/app/static/js/items.js
index 0574112d2050b53b062d37f912eda1400e7dbfe4..572918819a1b7c18ad771e46bc30ae5409889b3e 100644
--- a/app/static/js/items.js
+++ b/app/static/js/items.js
@@ -32,7 +32,32 @@ $(document).ready(function() {
     $(this).html(converter.makeHtml(raw));
   });
 
+
+  // export all items to excel file
+  function download_excel() {
+    var $modal = $("#downloadExcelModal");
+    $.ajax({
+      url: $SCRIPT_ROOT + "/inventory/items/_generate_excel_file",
+      method: "GET",
+      success: function(data, status, request) {
+        $modal.modal({
+          backdrop: "static",
+          keyboard: false
+        });
+        status_url = request.getResponseHeader('Location');
+        check_job_status(status_url, $modal);
+      },
+      error: function(jqXHR, textStatus, errorThrown) {
+        $modal.modal('hide');
+        flash_alert(JSON.parse(jqXHR.responseText).message, "danger", false);
+      }
+   });
+  }
+
   var items_table =  $("#items_table").DataTable({
+    dom: "<'row'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-3 text-right'><'col-sm-12 col-md-3'f>>" +
+    "<'row'<'col-sm-12'tr>>" +
+    "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
     "ajax": {
       "url": $SCRIPT_ROOT + "/inventory/_retrieve_items"
     },
@@ -68,4 +93,18 @@ $(document).ready(function() {
     ]
   });
 
+  new $.fn.dataTable.Buttons(items_table, {
+    buttons: [
+      {
+        text: '<span class="oi oi-data-transfer-download" title="Export to excel file" aria-hidden="true"></span> Excel',
+        className: "btn-outline-secondary",
+        action: function ( e, dt, node, conf ) {
+          download_excel();
+        }
+      }
+    ]
+  });
+
+  items_table.buttons().container().appendTo("#items_table_wrapper .col-md-3:eq(0)");
+
 });
diff --git a/app/tasks.py b/app/tasks.py
index f85b4f28e7e09b9c5572ca99f380d0eb2940057a..2030b905d94efa95ae0d117941094ae82bec43b4 100644
--- a/app/tasks.py
+++ b/app/tasks.py
@@ -12,11 +12,13 @@ This module implements tasks to run.
 import time
 import traceback
 import tower_cli
+from datetime import datetime
 from flask import current_app
 from flask_login import current_user
 from rq import Worker, get_current_job
+from openpyxl import Workbook
 from .extensions import db
-from . import models
+from . import models, utils
 
 
 class TaskWorker(Worker):
@@ -164,3 +166,30 @@ def launch_job_template(job_template, **kwargs):
     # Monitor the job until done
     result = resource.monitor(pk=result["id"])
     return result
+
+
+def generate_items_excel_file():
+    """Export all inventory items to an excel file
+
+    Return the name of the file
+    """
+    job = get_current_job()
+    name = f"items-{datetime.utcnow().strftime('%Y%m%d_%H%M')}.xlsx"
+    full_path = utils.unique_filename(current_app.config["CSENTRY_STATIC_FILES"] / name)
+    # Instead of loading all items at once, we perform several smaller queries
+    pagination = models.Item.query.order_by(models.Item.created_at).paginate(1, 100)
+    wb = Workbook()
+    ws = wb.active
+    # Add header
+    # Note that we rely on the fact that dict keep their order in Python 3.6
+    # (this is official in 3.7)
+    ws.append(list(pagination.items[0].to_dict().keys()))
+    while pagination.items:
+        for item in pagination.items:
+            ws.append([val for val in item.to_row_dict().values()])
+        job.meta["progress"] = int(100 * pagination.page / pagination.pages)
+        current_app.logger.debug(f"progress: {job.meta['progress']}")
+        job.save_meta()
+        pagination = pagination.next()
+    wb.save(full_path)
+    return full_path.name
diff --git a/app/templates/_helpers.html b/app/templates/_helpers.html
index 755e0f230710b190090afb7443580aec47547025..39d68af5d23f4e339be970161e1f77148d8545b8 100644
--- a/app/templates/_helpers.html
+++ b/app/templates/_helpers.html
@@ -179,3 +179,20 @@
     <figcaption class="figure-caption text-center">{{ description }}</figcaption>
   </figure>
 {%- endmacro %}
+
+{% macro waiting_for(title, label) -%}
+  <div class="modal" tabindex="-1" role="dialog" id="{{ label }}">
+    <div class="modal-dialog" role="document">
+      <div class="modal-content">
+        <div class="modal-header">
+          <h5 class="modal-title">{{ title }}</h5>
+        </div>
+        <div class="modal-body">
+          <div class="progress">
+            <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+{%- endmacro %}
diff --git a/app/templates/base.html b/app/templates/base.html
index 9e7533c6ef449c7bcfc94733a108fb0ba6dbe64a..fffb97962e0db2d6d34da70aa6a88eb852ed5a9e 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -36,6 +36,7 @@
           <a class="nav-item nav-link" href="{{ config['DOCUMENTATION_URL'] }}" target="_blank">Help</a>
         </div>
         <div class="navbar-nav">
+          {% block navbar_right %}{% endblock %}
           {% if current_user.is_authenticated %}
           <div class="dropdown {{ is_active(path == "/user/profile") }}">
             <a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -47,7 +48,6 @@
             </div>
           </div>
           {% endif %}
-          {% block navbar_right %}{% endblock %}
         </div>
       </div>
   </div>
diff --git a/app/templates/inventory/items.html b/app/templates/inventory/items.html
index 0940e5578425cc4cede52cfd618e0b648d58a94c..31f59bfcfd8731a4428cb0ebe9dc784f670f6529 100644
--- a/app/templates/inventory/items.html
+++ b/app/templates/inventory/items.html
@@ -1,9 +1,10 @@
 {% extends "base-fluid.html" %}
-{% from "_helpers.html" import is_active %}
+{% from "_helpers.html" import is_active, waiting_for %}
 
 {% block title %}Items - CSEntry{% endblock %}
 
 {% block main %}
+  {{ waiting_for("Please wait while your file is being prepared...", "downloadExcelModal") }}
   {% set path = request.path %}
   <ul class="nav nav-tabs">
     <li class="nav-item">
@@ -18,7 +19,7 @@
   <br>
 
   {% block items_main %}
-  <table id="items_table" class="table table-bordered table-hover table-sm" cellspacing="0" width="100%">
+  <table id="items_table" class="table table-bordered table-hover table-sm" style="width:100%">
     <thead>
       <tr>
         <th>Id</th>
diff --git a/app/utils.py b/app/utils.py
index 003a1cb1bf74abec59327a2d87e579c7f6b48a11..a70a9e1f99274b61b9997a6ccd80b1ec0ed5d707 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -16,7 +16,8 @@ import random
 import sqlalchemy as sa
 import dateutil.parser
 import yaml
-from flask import current_app
+from pathlib import Path
+from flask import current_app, jsonify, url_for
 from flask.globals import _app_ctx_stack, _request_ctx_stack
 from flask_login import current_user
 
@@ -248,3 +249,33 @@ def trigger_core_services_update():
     current_app.logger.info(f"Launch new job to update core services: {job_template}")
     task = current_user.launch_task("trigger_core_services_update", **kwargs)
     return task
+
+
+def redirect_to_job_status(job_id):
+    """
+    The answer to a client request, leading it to regularly poll a job status.
+
+    :param job_id: The id of the job started, which needs to be pulled by the client
+    :type job_id: rq.job_id
+    :return: HTTP response
+    """
+    return jsonify({}), 202, {"Location": url_for("main.job_status", job_id=job_id)}
+
+
+def unique_filename(filename):
+    """Return an unique filename
+
+    :param filename: filename that should be unique
+    :returns: unique filename
+    """
+    p = Path(filename)
+    if not p.exists():
+        return filename
+    base = p.with_suffix("")
+    nb = 1
+    while True:
+        unique = Path(f"{base}-{nb}{p.suffix}")
+        if not unique.exists():
+            break
+        nb += 1
+    return unique
diff --git a/docs/_static/inventory.png b/docs/_static/inventory.png
index f1e2e22d1d40e52ea59837bca873d499569534b8..356beb2b03cba7c021ca49216e97f70a4d1691d5 100644
Binary files a/docs/_static/inventory.png and b/docs/_static/inventory.png differ
diff --git a/docs/inventory.rst b/docs/inventory.rst
index 26d7737965de4ac72b756615eec8795b27fdba5e..dd3cd23f70b803becf57cb9fe4941ae8a61836c4 100644
--- a/docs/inventory.rst
+++ b/docs/inventory.rst
@@ -31,6 +31,9 @@ Note that the search is case-sensitive!
 
 .. image:: _static/inventory.png
 
+You can export all items to an excel file by clicking on the *Excel* button.
+Note that it might take some time depending on the number of items in the database.
+
 Clicking on the **ICS id** of an item will take you to the *View item* page.
 
 .. image:: _static/view_item.png
diff --git a/requirements-to-freeze.txt b/requirements-to-freeze.txt
index 5fea95bc6cdf8d04b740e0b57574978c00202568..7d087bb041c85a3eaaa52019a57e8b4c96750253 100644
--- a/requirements-to-freeze.txt
+++ b/requirements-to-freeze.txt
@@ -25,3 +25,4 @@ rq
 rq-dashboard
 sqlalchemy-citext
 sqlalchemy-continuum
+openpyxl
diff --git a/requirements.txt b/requirements.txt
index 87512fb06a41ba853a9e93feefac5de935af6718..21a2a7bf853d4036b38f77d32f4bd0d14fa55e4c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,6 +6,7 @@ certifi==2018.4.16
 chardet==3.0.4
 click==6.7
 colorama==0.3.9
+et-xmlfile==1.0.1
 Flask==1.0.2
 Flask-Admin==1.5.1
 Flask-Caching==1.4.0
@@ -21,10 +22,12 @@ Flask-SQLAlchemy==2.3.2
 Flask-WTF==0.14.2
 idna==2.7
 itsdangerous==0.24
+jdcal==1.4
 Jinja2==2.10
 ldap3==2.5.1
 Mako==1.0.7
 MarkupSafe==1.0
+openpyxl==2.5.7
 Pillow==5.2.0
 psycopg2==2.7.5
 pyasn1==0.4.4
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..68542e37e826498e1187393316397e213fa08261
--- /dev/null
+++ b/tests/unit/test_utils.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+"""
+tests.unit.test_utils
+~~~~~~~~~~~~~~~~~~~~~
+
+This module defines utils tests.
+
+:copyright: (c) 2018 European Spallation Source ERIC
+:license: BSD 2-Clause, see LICENSE for more details.
+
+"""
+from pathlib import Path
+from app import utils
+
+
+class TestUniqueFilename:
+    def test_no_file(self, tmpdir):
+        p = tmpdir.join("test.xlsx")
+        assert utils.unique_filename(p) == Path(p)
+
+    def test_one_not_related_file(self, tmpdir):
+        p = tmpdir.join("test.xlsx")
+        tmpdir.join("foo.xlsx").write("foo")
+        assert utils.unique_filename(p) == Path(p)
+
+    def test_one_file(self, tmpdir):
+        p = tmpdir.join("test.xlsx")
+        p.write("Hello")
+        assert utils.unique_filename(p) == Path(tmpdir.join("test-1.xlsx"))
+
+    def test_several_files(self, tmpdir):
+        p = tmpdir.join("test.xlsx")
+        p.write("Hello")
+        tmpdir.join("test-1.xlsx").write("foo")
+        tmpdir.join("test-2.xlsx").write("foo")
+        assert utils.unique_filename(p) == Path(tmpdir.join("test-3.xlsx"))
+
+    def test_first_available(self, tmpdir):
+        p = tmpdir.join("test.xlsx")
+        p.write("Hello")
+        tmpdir.join("test-2.xlsx").write("foo")
+        assert utils.unique_filename(p) == Path(tmpdir.join("test-1.xlsx"))
+
+    def test_no_extension(self, tmpdir):
+        p = tmpdir.join("test")
+        p.write("Hello")
+        assert utils.unique_filename(p) == Path(tmpdir.join("test-1"))