7 GDPR-Compliant Google Analytics Alternatives in Europe (2026)

С
Сергей Несмачный
06.09.2026
7 мин чтения

In short: if your business is in Europe, you can replace Google Analytics with a tool that stores data in the EU, works without cookie banners and still shows you everything a small or mid-sized company needs. Seven options are worth your time in 2026: EuroMetrics, Plausible, Fathom, Simple Analytics, Matomo, Umami and Piwik PRO. Below is how they differ, who each one suits, and how to pick in ten minutes.

Why European companies are leaving Google Analytics

Three reasons keep coming up in client projects. First, the legal one: data protection authorities in Austria, France, Italy and Denmark have found Google Analytics setups incompatible with GDPR because visitor data reaches US servers. Second, the practical one: a consent banner is required, and 30 to 40 percent of visitors click “reject”, so the reports are missing a third of the traffic anyway. Third, complexity: GA4 is built for analysts, while a business owner wants to see visitors, sources, top pages and conversions on one screen.

Every tool in this list solves the first two problems by design. They differ in where the company sits, how the data is hosted, whether the code is open, and what you pay.

Comparison: 7 GDPR-compliant Google Analytics alternatives

Tool Company based in Data hosting Cookieless Open source Free tier Best for
EuroMetrics Portugal (EU) EU only (Paris, Frankfurt, Amsterdam) Yes Open-source core Yes, 10,000 events/month EU SMEs and agencies that want analytics plus SEO, speed and uptime tools in one place
Plausible Estonia (EU) EU (Germany) Yes Yes (AGPL), self-host possible Trial only Teams that want the simplest possible dashboard
Fathom Canada EU isolation available Yes No Trial only Marketers who want polished reports and email digests
Simple Analytics Netherlands (EU) EU (Netherlands) Yes No Trial only Privacy-first brands that want zero personal data stored
Matomo New Zealand (cloud hosted in EU) EU cloud or your own server Optional Yes (GPL), self-host free Self-hosted is free Companies that need GA-style depth: funnels, heatmaps, e-commerce
Umami USA (open-source project) Your own server, or Umami Cloud Yes Yes (MIT) Self-hosted is free Developers happy to run a Docker container themselves
Piwik PRO Poland (EU) EU Optional No Yes, Core plan Enterprises and public sector with compliance teams
Checked in September 2026. Pricing and limits change often, so confirm on each vendor’s site before you sign up.

The seven tools, one paragraph each

1. EuroMetrics

A privacy-first analytics platform built in Portugal for EU websites. Cookieless tracking with a script under 2 KB, EU-only data residency, and a free tier of 10,000 events per month. What sets it apart is the bundle: the same account gives you an SEO checker, a speed test, uptime monitoring, a URL shortener and a cookie scanner, which most competitors sell separately or not at all. White-label reports are included on paid plans, so agencies can hand dashboards to clients. My team and I built it, and it runs the analytics on this site. Here is why we built it and how it compares in detail.

2. Plausible

The best-known lightweight alternative. One page, one screen, no cookies, servers in Germany, open-source code under AGPL that you can self-host. It deliberately does less than Google Analytics: no user-level data, no funnels beyond simple goals. If your team only ever looked at the GA overview page, Plausible will feel like a relief.

3. Fathom

Canadian company with an EU isolation option so that EU visitor data never leaves European servers. Cookieless, fast, with clean email reports and uptime monitoring included. The code is closed and there is no free tier, but the product is stable and the support is quick. A good choice for marketing teams that want reports without configuration.

4. Simple Analytics

Dutch company, data in the Netherlands, no cookies and no personal data stored at all, which makes the privacy policy conversation with legal very short. The dashboard is minimal and the pricing is per site. Good for brands whose positioning depends on privacy and who want to be able to say so honestly.

5. Matomo

The closest thing to a full Google Analytics replacement: events, funnels, heatmaps, session recordings, e-commerce reports and a tag manager. Open source, free when self-hosted, with a paid EU-hosted cloud. Cookieless mode exists but is a setting you have to turn on. Expect more setup work and a heavier interface in exchange for the depth.

6. Umami

An open-source project under the MIT license that you can run in a Docker container on any EU server in about fifteen minutes. Cookieless, simple, fast, and free if you host it yourself. There is no support and no extra tools, so it suits developers and small teams that are comfortable maintaining a server. EuroMetrics started from the Umami codebase and added the EU hosting, the tooling and the support around it.

7. Piwik PRO

Polish enterprise platform with a free Core plan and EU hosting. It keeps Google-style depth, adds a consent manager and audit features that compliance teams ask for, and is common in banking, healthcare and government. For a small business it is more than you need; for a regulated one it may be the only realistic option.

How to choose in ten minutes

  • You want the fewest decisions: Plausible, Simple Analytics or Fathom. Pick by the dashboard you like most.
  • You want analytics plus SEO, speed and uptime in one account: EuroMetrics.
  • You need funnels, heatmaps and e-commerce reports: Matomo or Piwik PRO.
  • You have a developer and want €0 hosting costs: self-hosted Umami or Matomo on an EU VPS.
  • You are in a regulated industry: Piwik PRO, or Matomo on your own infrastructure.

Whatever you pick, migrate the same way: install the new script next to Google Analytics for two to four weeks, compare the numbers, define the three or four goals you actually act on, then remove GA and the consent banner it required. The banner going away is usually the moment clients notice the difference in their data.

Frequently Asked Questions

Is Google Analytics GDPR compliant in 2026?

Not out of the box. Data protection authorities in Austria, France, Italy and Denmark have ruled that standard Google Analytics setups transfer personal data to the US unlawfully. Compliance requires consent banners, IP anonymisation and extra agreements, and even then a third of visitors decline tracking.

What is the best Google Analytics alternative for a European small business?

For most small and mid-sized companies in the EU, a cookieless, EU-hosted tool such as EuroMetrics, Plausible or Simple Analytics covers everything they look at: visitors, sources, top pages, devices, countries and goals. EuroMetrics adds free SEO, speed and uptime tools, which matters if you do not want three more subscriptions.

Do I still need a cookie banner with these tools?

Not for analytics. Cookieless tools do not store identifiers on the visitor’s device and do not build personal profiles, so the analytics itself does not require consent under GDPR and the ePrivacy rules. You may still need a banner for other cookies, such as advertising pixels or embedded video.

Can I keep Google Analytics and add a European tool?

Yes, and it is the safest way to migrate. Run both for a few weeks, compare traffic and conversions, then decide. Most teams find the cookieless tool reports 30 to 40 percent more visitors because nobody has to accept anything first.

Is self-hosting Umami or Matomo really free?

The software is free. You pay for a server, usually €5 to €20 per month for a small EU VPS, plus the time to update and back it up. If nobody on the team wants that job, a hosted EU service is cheaper in practice.

Need help choosing or migrating? My team and I set up EU analytics as part of every website we build. See what we do or tell us about your project.

С

Автор

Сергей Несмачный

Поделиться:

Другие статьи

Продолжайте читать блог

Is There Finally a Real Alternative to WordPress? (2026)

Is There Finally a Real Alternative to WordPress? (2026)

I built a client site on Cloudflare + EmDash CMS instead of WordPress: D1 database, R2 storage, built-in business email and CRM, all for $5/month.

11.08.2026
Создайте RAG-чатбот на основе контента WordPress (2026)

Создайте RAG-чатбот на основе контента WordPress (2026)

# Build a RAG Chatbot on WordPress Content: Complete Step-by-Step Guide ## What You'll Build A RAG (Retrieval-Augmented Generation) chatbot that: - Fetches WordPress posts via REST API - Converts posts into embeddings - Stores embeddings in Qdrant vector database - Retrieves relevant posts for user queries - Generates answers with proper citations ## Prerequisites - WordPress site with REST API enabled (WordPress 4.7+) - Python 3.10+ - Qdrant instance (local or cloud) - OpenAI API key (or alternative LLM) - Basic knowledge of Python and APIs ## Architecture Overview ``` WordPress REST API ↓ Python Script ↓ Embeddings (OpenAI) ↓ Qdrant Storage ↓ RAG Pipeline ↓ LLM Response + Citations ``` ## Step 1: Set Up Your Environment ### Install Required Dependencies ```bash pip install requests python-dotenv qdrant-client openai langchain python-dotenv ``` ### Create Environment File Create `.env`: ``` WORDPRESS_URL=https://your-wordpress-site.com WORDPRESS_USERNAME=your_username WORDPRESS_PASSWORD=your_password OPENAI_API_KEY=your_openai_key QDRANT_URL=http://localhost:6333 QDRANT_API_KEY=your_qdrant_key_or_leave_empty ``` ## Step 2: Fetch WordPress Posts via REST API ### Create WordPress Data Fetcher Create `fetch_wordpress.py`: ```python import requests import json from typing import List, Dict import os from dotenv import load_dotenv load_dotenv() class WordPressFetcher: def __init__(self, base_url: str): self.base_url = base_url.rstrip('/') self.rest_endpoint = f"{self.base_url}/wp-json/wp/v2" def fetch_all_posts(self, per_page: int = 100) -> List[Dict]: """ Fetch all posts from WordPress using pagination """ posts = [] page = 1 while True: url = f"{self.rest_endpoint}/posts" params = { 'per_page': per_page, 'page': page, 'status': 'publish' } response = requests.get(url, params=params) if response.status_code != 200: print(f"Error fetching page {page}: {response.status_code}") break page_posts = response.json() if not page_posts: break posts.extend(page_posts) page += 1 print(f"Fetched page {page - 1}, total posts: {len(posts)}") return posts def extract_post_content(self, post: Dict) -> Dict: """ Extract relevant content from WordPress post """ return { 'id': str(post['id']), 'title': post['title']['rendered'], 'content': post['content']['rendered'], 'excerpt': post['excerpt']['rendered'], 'url': post['link'], 'date': post['date'], 'author': post['author'], 'categories': post.get('categories', []) } def clean_html(self, html_content: str) -> str: """ Remove HTML tags from content """ from html.parser import HTMLParser class MLStripper(HTMLParser): def __init__(self): super().__init__() self.reset() self.strict = False self.convert_charrefs = True self.text = [] def handle_data(self, d): self.text.append(d) def get_data(self): return ''.join(self.text) stripper = MLStripper() stripper.feed(html_content) return stripper.get_data() # Usage if __name__ == "__main__": wordpress_url = os.getenv('WORDPRESS_URL') fetcher = WordPressFetcher(wordpress_url) posts = fetcher.fetch_all_posts() print(f"Total posts fetched: {len(posts)}") # Save to file for inspection with open('wordpress_posts.json', 'w') as f: json.dump([fetcher.extract_post_content(p) for p in posts], f, indent=2) ``` ### Run the Fetcher ```bash python fetch_wordpress.py ``` This creates `wordpress_posts.json` with all your posts. ## Step 3: Create Embeddings ### Set Up Embedding Generator Create `embeddings.py`: ```python from openai import OpenAI import os from dotenv import load_dotenv load_dotenv() class EmbeddingGenerator: def __init__(self, api_key: str = None): self.client = OpenAI(api_key=api_key or os.getenv('OPENAI_API_KEY')) self.model = "text-embedding-3-small" def generate_embedding(self, text: str) -> list: """ Generate embedding for a single text """ response = self.client.embeddings.create( input=text, model=self.model ) return response.data[0].embedding def generate_batch_embeddings(self, texts: list) -> list: """ Generate embeddings for multiple texts (batch is more efficient) """ response = self.client.embeddings.create( input=texts, model=self.model ) return [item.embedding for item in response.data] # Test if __name__ == "__main__": generator = EmbeddingGenerator() test_embedding = generator.generate_embedding("Test content for embedding") print(f"Embedding dimension: {len(test_embedding)}") print(f"First 5 values: {test_embedding[:5]}") ``` ## Step 4: Set Up Qdrant Vector Database ### Start Qdrant Locally (Docker) ```bash docker run -p 6333:6333 -p 6334:6334 \ -v /path/to/qdrant/storage:/qdrant/storage:z \ qdrant/qdrant:latest ``` Access Qdrant dashboard at `http://localhost:6333/dashboard` ### Create Qdrant Manager Create `qdrant_manager.py`: ```python from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PointStruct from typing import List, Dict, Tuple import os from dotenv import load_dotenv load_dotenv() class QdrantManager: def __init__(self, url: str = None, api_key: str = None): self.url = url or os.getenv('QDRANT_URL', 'http://localhost:6333') self.api_key = api_key or os.getenv('QDRANT_API_KEY') self.client = QdrantClient( url=self.url, api_key=self.api_key if self.api_key else None ) self.collection_name = "wordpress_posts" def create_collection(self, vector_size: int = 1536): """ Create collection for storing embeddings 1536 is the dimension for text-embedding-3-small """ try: self.client.recreate_collection( collection_name=self.collection_name, vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE), ) print(f"Collection '{self.collection_name}' created") except Exception as e: print(f"Error creating collection: {e}") def add_posts(self, posts: List[Dict], embeddings: List[List[float]]): """ Add posts with their embeddings to Qdrant """ points = [] for idx, (post, embedding) in enumerate(zip(posts, embeddings)): point = PointStruct( id=int(post['id']), vector=embedding, payload={ 'title': post['title'], 'content': post['content'], 'excerpt': post['excerpt'], 'url': post['url'], 'date': post['date'], } ) points.append(point) self.client.upsert( collection_name=self.collection_name, points=points, ) print(f"Added {len(points)} posts to Qdrant") def search(self, query_embedding: List[float], limit: int = 5) -> List[Dict]: """ Search for similar posts """ results = self.client.search( collection_name=self.collection_name, query_vector=query_embedding, limit=limit, ) retrieved_posts = [] for result in results: retrieved_posts.append({ 'id': result.id, 'score': result.score, 'title': result.payload['title'], 'content': result.payload['content'], 'url': result.payload['url'], 'date': result.payload['date'], }) return retrieved_posts def get_collection_info(self): """ Get collection statistics """ info = self.client.get_collection(self.collection_name) return info # Test if __name__ == "__main__": manager = QdrantManager() info = manager.get_collection_info() print(f"Collection info: {info}") ``` ## Step 5: Index WordPress Posts into Qdrant ### Create Indexing Script Create `index_posts.py`: ```python import json from fetch_wordpress import WordPressFetcher from embeddings import EmbeddingGenerator from qdrant_manager import QdrantManager import os from dotenv import load_dotenv from html.parser import HTMLParser load_dotenv() class HTMLStripper(HTMLParser): def __init__(self): super().__init__() self.reset() self.strict = False self.convert_charrefs = True self.text = [] def handle_data(self, d): self.text.append(d) def get_data(self): return ''.join(self.text) def strip_html(html): stripper = HTMLStripper() stripper.feed(html) return stripper.get_data() def index_wordpress_posts(): """ Main indexing pipeline """ print("Step 1: Fetching WordPress posts...") wordpress_url = os.getenv('WORDPRESS_URL') fetcher = WordPressFetcher(wordpress_url) raw_posts = fetcher.fetch_all_posts() # Extract and clean content posts = [] for raw_post in raw_posts: post = fetcher.extract_post_content(raw_post) post['content'] = strip_html(post['content']) post['excerpt'] = strip_html(post['excerpt']) posts.append(post) print(f"Extracted {len(posts)} posts") print("\nStep 2: Generating embeddings...") generator = EmbeddingGenerator() # Combine title and content for embedding texts_to_embed = [ f"{post['title']}\n{post['content'][:500]}" # Use first 500 chars for post in posts ] embeddings = generator.generate_batch_embeddings(texts_to_embed) print(f"Generated {len(embeddings)} embeddings") print("\nStep 3: Indexing into Qdrant...") manager = QdrantManager() manager.create_collection(vector_size=1536) manager.add_posts(posts, embeddings) print("\nStep 4: Verifying...") info = manager.get_collection_info() print(f"Collection has {info.points_count} points") print("✓ Indexing complete!") if __name__ == "__main__": index_wordpress_posts() ``` ### Run the Indexer ```bash python index_posts.py ``` This will: - Fetch all posts - Generate embeddings - Store in Qdrant - Display progress ## Step 6: Build the RAG Chatbot ### Create RAG Pipeline Create `rag_chatbot.py`: ```python from embeddings import EmbeddingGenerator from qdrant_manager import QdrantManager from openai import OpenAI from typing import List, Dict, Tuple import os from dotenv import load_dotenv load_dotenv() class RAGChatbot: def __init__(self): self.embedding_generator = EmbeddingGenerator() self.qdrant_manager = QdrantManager() self.llm_client = OpenAI(api_key=os.getenv('OPENAI_API_KEY')) self.model = "gpt-4o-mini" def retrieve_relevant_posts(self, query: str, top_k: int = 5) -> List[Dict]: """ Retrieve relevant posts for a query """ # Generate embedding for the query query_embedding = self.embedding_generator.generate_embedding(query) # Search in Qdrant results = self.qdrant_manager.search(query_embedding, limit=top_k) return results def generate_answer_with_citations( self, query: str, retrieved_posts: List[Dict] ) -> Tuple[str, List[Dict]]: """ Generate answer using LLM with citations """ # Format context from retrieved posts context = self._format_context(retrieved_posts) # Create prompt system_prompt = """You are a helpful assistant answering questions about WordPress blog posts. Use the provided post content to answer questions accurately. Always cite the source posts by title and URL in your response. If you can't find relevant information, say so explicitly.""" user_prompt = f"""Based on the following posts, answer the question: "{query}" POSTS: {context} Please provide a comprehensive answer with citations to the source posts.""" # Call LLM response = self.llm_client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], temperature=0.7, max_tokens=1000 ) answer = response.choices[0].message.content return answer, retrieved_posts def _format_context(self, posts: List[Dict]) -> str: """ Format retrieved posts into a context string """ context_parts = [] for i, post in enumerate(posts, 1): context_parts.append(f""" Post {i}: {post['title']} URL: {post['url']} Relevance Score: {post['score']:.3f} Content: {post['content'][:800]}... ---""") return "\n".join(context_parts) def chat(self, query: str, top_k: int = 5) ->

26.06.2026
Почему ваш сайт невидим для ChatGPT (и как это исправить за 14 дней)

Почему ваш сайт невидим для ChatGPT (и как это исправить за 14 дней)

ИИ-ассистенты теперь решают, кого рекомендовать. Большинство сайтов для них нечитаемы. Вот 5 бесплатных проверок и как работает разовое техническое исправление.

15.06.2026